Magic Link Sign Up and Login for SaaS
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 they are in. If the account exists, I sign them in. If it does not, I create it.
I use this Magic Link flow across my products. MyOG.social is the example here because it has the cleanest version of the implementation.
I 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.
Sign Up and Login Are the Same Operation
I don’t ask the user whether they want to sign up or sign in.
That distinction is useful to the app, not the user. The user just wants access.
So the backend does this:
- verify the email through a Magic Link
- look up the user by normalized email
- create the user if one does not exist
- return the same session shape either way
In MyOG.social, that looks like this:
let user = await dbService
.db()
.select()
.from(users)
.where(eq(users.email, email))
.limit(1)
.then((rows) => rows[0])
if (!user) {
const trialExpiresAt = new Date(
Date.now() + FREE_TRIAL_DAYS * 24 * 60 * 60 * 1000
)
const newUser = await dbService
.db()
.insert(users)
.values({
email,
emailSource: "magicLink",
trialCreditsRemaining: FREE_TRIAL_CREDITS,
trialExpiresAt,
})
.returning()
user = newUser[0]
}
That 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.
The frontend can still say “Sign up” or “Sign in” depending on context. The backend does not care.
What the Table Stores
The Magic Link table stores only what the login flow needs.
export const magicLinks = pgTable(
"magic_links",
{
id: serial("id").primaryKey(),
email: varchar("email").notNull(),
token: varchar("token").notNull(),
code: varchar("code").notNull(),
used: boolean("used").notNull().default(false),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(table) => {
return {
tokenIndex: index().on(table.token),
codeIndex: index().on(table.code),
emailIndex: index().on(table.email),
}
}
)
The key fields:
tokenfor the link in the emailcodefor manual entryusedso the link can only be used onceexpiresAtso old links stop working
I also track how the email first came in:
export const emailSourceEnum = pgEnum("email_source", [
"googleLogin",
"magicLink",
])
This 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.
Sending the Magic Link
app.post(
"/auth/magic-link/send",
{
preHandler: [zodValidateBody(sendMagicLinkSchema)],
},
sendMagicLink
)
The schema only needs an email:
const sendMagicLinkSchema = z.object({
email: z.string().email(),
})
The controller normalizes the email, creates a random token, creates a 6-digit code, and stores both with a 15-minute expiry.
function generateToken(): string {
return crypto.randomBytes(32).toString("hex")
}
function generateCode(): string {
return crypto.randomInt(100000, 1000000).toString()
}
const email = normalizeEmail(rawEmail)
const token = generateToken()
const code = generateCode()
const expiresAt = new Date(Date.now() + 15 * 60 * 1000)
await dbService.db().insert(magicLinks).values({
email,
token,
code,
expiresAt,
})
The link uses the configured frontend hostname:
const magicLinkURL = `${env.FRONTEND_HOSTNAME}/magic-link-verify?token=${token}`
I don’t hardcode production URLs in the auth code. Local dev, staging, and production all need to send different links.
The email includes both the link and the code:
Click the link below to sign in to myog.social:
https://myog.social/magic-link-verify?token=...
Or enter this verification code on the sign-in page:
123456
This link and code will expire in 15 minutes.
The 6-digit code looks like a small detail, but it matters.
Some 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.
Verifying the Link
The verify endpoint accepts either a token or a code.
const verifyMagicLinkSchema = z
.object({
token: z.string().optional(),
code: z.string().optional(),
})
.refine((data) => data.token || data.code, {
message: "Either token or code must be provided",
})
For a token, I look up a matching record that has not been used and has not expired.
const results = await dbService
.db()
.select()
.from(magicLinks)
.where(
and(
eq(magicLinks.token, token),
eq(magicLinks.used, false),
gt(magicLinks.expiresAt, now)
)
)
.limit(1)
const magicLink = results[0]
The code path is the same shape, just eq(magicLinks.code, code) instead of the token check.
If there is no match, the answer is deliberately vague:
return reply.status(400).send({ error: "Invalid or expired link/code" })
No need to tell the caller whether the token existed, expired, or was already used.
When there is a match, mark it used.
await dbService
.db()
.update(magicLinks)
.set({ used: true })
.where(eq(magicLinks.id, magicLink.id))
I 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.
Then create the session.
const jwtToken = await reply.jwtSign({ email })
const creditsInfo = calculateCreditsInfo(user)
return reply.send({
token: jwtToken,
user: {
id: user.id,
email: user.email,
customerID: user.customerID,
accountHint,
hasPaidSubscription,
},
credits: creditsInfo,
})
I 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.
The Frontend Has Two States
The Vue page has two states.
First: enter email.
<Input
id="email"
v-model="email"
type="email"
placeholder="you@example.com"
@keyup.enter="sendMagicLink"
/>
<Button @click="sendMagicLink">
Send Magic Link
</Button>
After the email is sent, it switches to the code state.
<Input
v-for="(digit, index) in codeDigits"
:key="index"
v-model="codeDigits[index]"
type="text"
inputmode="numeric"
pattern="[0-9]*"
maxlength="1"
@paste="handleCodePaste"
/>
The paste handler strips non-digits and verifies automatically when it gets 6 digits.
const pastedData = event.clipboardData?.getData("text") || ""
const digits = pastedData.replace(/\D/g, "").slice(0, 6)
for (let i = 0; i < 6; i++) {
codeDigits.value[i] = digits[i] || ""
}
if (digits.length === 6) {
await verifyCode()
}
Nothing fancy, but it removes friction. People paste codes.
The email link goes to a separate verify page:
onMounted(async () => {
const token = route.query.token as string
if (!token) {
errorMessage.value = "Invalid magic link"
isVerifying.value = false
return
}
const result = await appStore.verifyMagicLink({ token })
if (result.success) {
void router.push("/")
} else {
errorMessage.value = result.error || "Failed to verify magic link."
isVerifying.value = false
}
})
Click link, verify token, store session, go to the app. That’s it.
Pinia Owns the Session
The frontend store has the usual auth state:
const user = ref<User | null>(null)
const jwtToken = ref<string | null>(null)
const credits = ref<CreditsInfo | null>(null)
const isAuthenticated = computed(() => !!user.value && !!jwtToken.value)
Sending the Magic Link is just a POST to /auth/magic-link/send.
Verifying stores the returned JWT and user:
jwtToken.value = data.token
user.value = data.user
credits.value = data.credits || null
localStorage.setItem(JWT_STORAGE_KEY, data.token)
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(data.user))
The rest of the app only asks whether the store has a session.
if (!appStore.isAuthenticated) {
await appStore.restoreSession()
}
Protected routes do not need to know whether the user came through Magic Link or Google.
Where Google Sign In Fits
Google Sign In sits beside Magic Link in the dialog.
<div id="googleSignInButton"></div>
<Button @click="goToMagicLink()" variant="outline">
Sign in with Magic Link
</Button>
The frontend loads Google Identity Services, renders Google’s button, receives an ID token, and sends it to the backend.
const success = await store.loginWithGoogle(response.credential)
The backend verifies the ID token with Google, extracts the email, and then follows the same find-or-create-user shape.
const ticket = await client.verifyIdToken({
idToken,
audience: env.GOOGLE_CLIENT_ID,
})
const payload = ticket.getPayload()
const email = normalizeEmail(payload.email)
I like this split:
- Google is fast for people with Google accounts
- Magic Link works for everyone else
- both return the same app session
I 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.
This auth flow is part of Stacknaut. I extracted it from the products I actually run.