{
"$type": "site.standard.document",
"description": "No passwords. No separate registration form. No \"confirm your email\" step after sign up. The user enters an email address, gets a link, clicks it, and",
"path": "/magic-link-sign-up-and-login-for-saas/",
"publishedAt": "2026-04-30T10:04:00.000Z",
"site": "at://did:plc:bryys25pc2fnagnyxqgsglhd/site.standard.publication/3mn26bjkkmh23",
"tags": [
"Web",
"Techniques"
],
"textContent": "No passwords. No separate registration form. No \"confirm your email\" step after sign up.\n\nThe user enters an email address, gets a link, clicks it, and they are in. If the account exists, I sign them in. If it does not, I create it.\n\nI use this Magic Link flow across my products. MyOG.social is the example here because it has the cleanest version of the implementation.\n\nI also support Google Sign In because it is the fastest path for Gmail users. But Magic Link is the one I rely on. It works for every email address, including non-Google accounts, company domains, and people who do not want another OAuth prompt.\n\nSIGN UP AND LOGIN ARE THE SAME OPERATION\n\nI don't ask the user whether they want to sign up or sign in.\n\nThat distinction is useful to the app, not the user. The user just wants access.\n\nSo the backend does this:\n\n * verify the email through a Magic Link\n * look up the user by normalized email\n * create the user if one does not exist\n * return the same session shape either way\n\nIn MyOG.social, that looks like this:\n\nlet user = await dbService\n .db()\n .select()\n .from(users)\n .where(eq(users.email, email))\n .limit(1)\n .then((rows) => rows[0])\n\nif (!user) {\n const trialExpiresAt = new Date(\n Date.now() + FREE_TRIAL_DAYS * 24 * 60 * 60 * 1000\n )\n\n const newUser = await dbService\n .db()\n .insert(users)\n .values({\n email,\n emailSource: \"magicLink\",\n trialCreditsRemaining: FREE_TRIAL_CREDITS,\n trialExpiresAt,\n })\n .returning()\n\n user = newUser[0]\n}\n\nThat one branch removes a surprising amount of product surface area. No \"create account\" screen. No \"already have an account?\" switch. No duplicate route that does the same thing with slightly different copy.\n\nThe frontend can still say \"Sign up\" or \"Sign in\" depending on context. The backend does not care.\n\nWHAT THE TABLE STORES\n\nThe Magic Link table stores only what the login flow needs.\n\nexport const magicLinks = pgTable(\n \"magic_links\",\n {\n id: serial(\"id\").primaryKey(),\n email: varchar(\"email\").notNull(),\n token: varchar(\"token\").notNull(),\n code: varchar(\"code\").notNull(),\n used: boolean(\"used\").notNull().default(false),\n expiresAt: timestamp(\"expires_at\").notNull(),\n createdAt: timestamp(\"created_at\").notNull().defaultNow(),\n },\n (table) => {\n return {\n tokenIndex: index().on(table.token),\n codeIndex: index().on(table.code),\n emailIndex: index().on(table.email),\n }\n }\n)\n\nThe key fields:\n\n * token for the link in the email\n * code for manual entry\n * used so the link can only be used once\n * expiresAt so old links stop working\n\nI also track how the email first came in:\n\nexport const emailSourceEnum = pgEnum(\"email_source\", [\n \"googleLogin\",\n \"magicLink\",\n])\n\nThis is not required for authentication. I keep it because it helps later. I can tell whether a user came from Google Sign In or Magic Link, and I can use that when debugging support issues or looking at conversion.\n\nSENDING THE MAGIC LINK\n\napp.post(\n \"/auth/magic-link/send\",\n {\n preHandler: [zodValidateBody(sendMagicLinkSchema)],\n },\n sendMagicLink\n)\n\nThe schema only needs an email:\n\nconst sendMagicLinkSchema = z.object({\n email: z.string().email(),\n})\n\nThe controller normalizes the email, creates a random token, creates a 6-digit code, and stores both with a 15-minute expiry.\n\nfunction generateToken(): string {\n return crypto.randomBytes(32).toString(\"hex\")\n}\n\nfunction generateCode(): string {\n return crypto.randomInt(100000, 1000000).toString()\n}\n\nconst email = normalizeEmail(rawEmail)\nconst token = generateToken()\nconst code = generateCode()\nconst expiresAt = new Date(Date.now() + 15 * 60 * 1000)\n\nawait dbService.db().insert(magicLinks).values({\n email,\n token,\n code,\n expiresAt,\n})\n\nThe link uses the configured frontend hostname:\n\nconst magicLinkURL = `${env.FRONTEND_HOSTNAME}/magic-link-verify?token=${token}`\n\nI don't hardcode production URLs in the auth code. Local dev, staging, and production all need to send different links.\n\nThe email includes both the link and the code:\n\nClick the link below to sign in to myog.social:\n\nhttps://myog.social/magic-link-verify?token=...\n\nOr enter this verification code on the sign-in page:\n\n123456\n\nThis link and code will expire in 15 minutes.\n\nThe 6-digit code looks like a small detail, but it matters.\n\nSome people open email on their phone and the app on their laptop. Some corporate email tools visit links before the user sees them. Some browsers get weird with logged-in state across profiles. A code gives the user another path without adding another auth system.\n\nVERIFYING THE LINK\n\nThe verify endpoint accepts either a token or a code.\n\nconst verifyMagicLinkSchema = z\n .object({\n token: z.string().optional(),\n code: z.string().optional(),\n })\n .refine((data) => data.token || data.code, {\n message: \"Either token or code must be provided\",\n })\n\nFor a token, I look up a matching record that has not been used and has not expired.\n\nconst results = await dbService\n .db()\n .select()\n .from(magicLinks)\n .where(\n and(\n eq(magicLinks.token, token),\n eq(magicLinks.used, false),\n gt(magicLinks.expiresAt, now)\n )\n )\n .limit(1)\n\nconst magicLink = results[0]\n\nThe code path is the same shape, just eq(magicLinks.code, code) instead of the token check.\n\nIf there is no match, the answer is deliberately vague:\n\nreturn reply.status(400).send({ error: \"Invalid or expired link/code\" })\n\nNo need to tell the caller whether the token existed, expired, or was already used.\n\nWhen there is a match, mark it used.\n\nawait dbService\n .db()\n .update(magicLinks)\n .set({ used: true })\n .where(eq(magicLinks.id, magicLink.id))\n\nI would wrap this in a transaction if I were rebuilding it today. The practical behavior is still fine for my current products, but the stricter version is better: find the row, mark it used, create or fetch the user, all as one unit.\n\nThen create the session.\n\nconst jwtToken = await reply.jwtSign({ email })\nconst creditsInfo = calculateCreditsInfo(user)\n\nreturn reply.send({\n token: jwtToken,\n user: {\n id: user.id,\n email: user.email,\n customerID: user.customerID,\n accountHint,\n hasPaidSubscription,\n },\n credits: creditsInfo,\n})\n\nI keep the JWT payload small. The frontend gets the user object in the response, but the token only needs enough identity for authenticated API requests.\n\nTHE FRONTEND HAS TWO STATES\n\nThe Vue page has two states.\n\nFirst: enter email.\n\n<Input\n id=\"email\"\n v-model=\"email\"\n type=\"email\"\n placeholder=\"you@example.com\"\n @keyup.enter=\"sendMagicLink\"\n/>\n\n<Button @click=\"sendMagicLink\">\n Send Magic Link\n</Button>\n\nAfter the email is sent, it switches to the code state.\n\n<Input\n v-for=\"(digit, index) in codeDigits\"\n :key=\"index\"\n v-model=\"codeDigits[index]\"\n type=\"text\"\n inputmode=\"numeric\"\n pattern=\"[0-9]*\"\n maxlength=\"1\"\n @paste=\"handleCodePaste\"\n/>\n\nThe paste handler strips non-digits and verifies automatically when it gets 6 digits.\n\nconst pastedData = event.clipboardData?.getData(\"text\") || \"\"\nconst digits = pastedData.replace(/\\D/g, \"\").slice(0, 6)\n\nfor (let i = 0; i < 6; i++) {\n codeDigits.value[i] = digits[i] || \"\"\n}\n\nif (digits.length === 6) {\n await verifyCode()\n}\n\nNothing fancy, but it removes friction. People paste codes.\n\nThe email link goes to a separate verify page:\n\nonMounted(async () => {\n const token = route.query.token as string\n\n if (!token) {\n errorMessage.value = \"Invalid magic link\"\n isVerifying.value = false\n return\n }\n\n const result = await appStore.verifyMagicLink({ token })\n if (result.success) {\n void router.push(\"/\")\n } else {\n errorMessage.value = result.error || \"Failed to verify magic link.\"\n isVerifying.value = false\n }\n})\n\nClick link, verify token, store session, go to the app. That's it.\n\nPINIA OWNS THE SESSION\n\nThe frontend store has the usual auth state:\n\nconst user = ref<User | null>(null)\nconst jwtToken = ref<string | null>(null)\nconst credits = ref<CreditsInfo | null>(null)\n\nconst isAuthenticated = computed(() => !!user.value && !!jwtToken.value)\n\nSending the Magic Link is just a POST to /auth/magic-link/send.\n\nVerifying stores the returned JWT and user:\n\njwtToken.value = data.token\nuser.value = data.user\ncredits.value = data.credits || null\n\nlocalStorage.setItem(JWT_STORAGE_KEY, data.token)\nlocalStorage.setItem(USER_STORAGE_KEY, JSON.stringify(data.user))\n\nThe rest of the app only asks whether the store has a session.\n\nif (!appStore.isAuthenticated) {\n await appStore.restoreSession()\n}\n\nProtected routes do not need to know whether the user came through Magic Link or Google.\n\nWHERE GOOGLE SIGN IN FITS\n\nGoogle Sign In sits beside Magic Link in the dialog.\n\n<div id=\"googleSignInButton\"></div>\n\n<Button @click=\"goToMagicLink()\" variant=\"outline\">\n Sign in with Magic Link\n</Button>\n\nThe frontend loads Google Identity Services, renders Google's button, receives an ID token, and sends it to the backend.\n\nconst success = await store.loginWithGoogle(response.credential)\n\nThe backend verifies the ID token with Google, extracts the email, and then follows the same find-or-create-user shape.\n\nconst ticket = await client.verifyIdToken({\n idToken,\n audience: env.GOOGLE_CLIENT_ID,\n})\n\nconst payload = ticket.getPayload()\nconst email = normalizeEmail(payload.email)\n\nI like this split:\n\n * Google is fast for people with Google accounts\n * Magic Link works for everyone else\n * both return the same app session\n\nI don't want password auth unless I have a specific reason to add it. Passwords mean reset flows, breach concerns, password manager weirdness, and another thing for users to maintain. Email-based auth is enough for the products I build.\n\nThis auth flow is part of Stacknaut. I extracted it from the products I actually run.",
"title": "Magic Link Sign Up and Login for SaaS"
}