Building an email verification flow that actually works
Verification looks like a weekend feature: generate a token, mail a link, flip a boolean. The version that survives real users has to handle links opened in a different browser, mail clients that fetch every URL in a message, people who click resend nine times, and the fact that a meaningful share of signups mistype their address.
5 min read
Decide what verification is for
Two different goals, and they lead to different designs.
Proving the address exists and belongs to them. This is the common case. A one-time link or code, checked once, done.
Gating access to the product. Harder, because you are now blocking a paying user behind an email you do not control the delivery of. Let people in and restrict the sensitive parts instead: they can use the app, but cannot invite teammates, change billing, or send anything from your product until the address is confirmed.
Blocking the whole product on verification converts worse and generates support load every time a message is slow.
Token design
The single most common mistake is storing the token as sent. Your verification tokens are password-equivalent: whoever holds one can take over the account it belongs to. Store a hash, exactly as you would a password.
import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
const TTL_MS = 24 * 60 * 60 * 1000;
export function createToken() {
const token = randomBytes(32).toString('base64url');
const hash = createHash('sha256').update(token).digest('hex');
return { token, hash, expiresAt: new Date(Date.now() + TTL_MS) };
}
export function hashOf(token: string) {
return createHash('sha256').update(token).digest('hex');
}
export function safeEqual(a: string, b: string) {
const x = Buffer.from(a);
const y = Buffer.from(b);
return x.length === y.length && timingSafeEqual(x, y);
}The rules behind that code:
- 256 bits of entropy from a cryptographic source. Not a UUID v4 from a library you have not checked, not a timestamp, never an incrementing id.
- Store the SHA-256 hash, compare in constant time. A database leak then yields nothing usable.
- Expire in hours, not days. Twenty-four hours suits verification. A password reset should be much shorter.
- One use. Delete or mark the row on success, inside the same transaction that marks the address verified.
- Bind it to the address it was issued for. If the user changes their email before clicking, the old token must not verify the new address.
Send it
import { createToken } from './verification';
export async function sendVerification(userId: string, email: string) {
const { token, hash, expiresAt } = createToken();
await db.verificationTokens.create({ userId, email, hash, expiresAt });
const link = `https://acme.com/verify?token=${token}`;
const res = await fetch('https://emails.sh/v1/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.EMAILSSH_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
from: 'Acme <hello@mail.acme.com>',
to: [email],
subject: 'Confirm your email address',
html: `<p>Confirm your address to finish setting up Acme.</p>
<p><a href="${link}">Confirm your email address</a></p>
<p>This link expires in 24 hours. If you did not sign up, ignore this message.</p>
<p>${link}</p>`,
text: `Confirm your address to finish setting up Acme:\n\n${link}\n\nThis link expires in 24 hours. If you did not sign up, ignore this message.`,
reply_to: 'support@acme.com',
idempotency_key: `verify:${hash}`
})
});
if (!res.ok) throw new Error(`verification send failed: ${res.status}`);
return res.json() as Promise<{ id: string; status: string }>;
}Print the raw URL in the body as well as the link. People forward these messages, paste them into another device, or read them in a client that strips anchors, and a visible URL rescues all three.
Handle the click
export const runtime = 'nodejs';
export async function GET(request: Request) {
const token = new URL(request.url).searchParams.get('token');
if (!token) return Response.redirect('/verify/invalid', 302);
const row = await db.verificationTokens.findByHash(hashOf(token));
if (!row) return Response.redirect('/verify/invalid', 302);
if (row.expiresAt < new Date()) return Response.redirect('/verify/expired', 302);
await db.transaction(async (tx) => {
await tx.users.markVerified(row.userId, row.email);
await tx.verificationTokens.deleteAllFor(row.userId);
});
return Response.redirect('/verify/done', 302);
}Deleting every outstanding token for that user, not only the one used, is what prevents an older link in an earlier message from working afterwards.
The failure modes nobody tests
Link scanners click your links. Corporate mail security, and some consumer clients, fetch every URL in a message to check it for malware. A one-time GET link is consumed before the human sees it, and they arrive to find it already used. Two mitigations: make the verification page a confirmation step with a POST behind a button, or accept the GET and treat a second visit from the same user as success rather than as an error.
Different browser, no session. The link opens in the default mail app browser, where the user is not signed in. Verification must not require a session. Verify the address from the token alone, then ask them to sign in.
Resend abuse. Someone types the same address into your signup form four hundred times. Rate limit resends per address and per IP, with a visible cooldown, and reuse the outstanding token instead of issuing a fresh one on every press.
Typos in the address. A meaningful share of signups contain a misspelled domain. Catch the obvious ones at the form, offer a correction, and make the address editable on the "check your email" screen. A user who cannot fix gmial.com without creating a second account is a user you lost.
Duplicate sends from a retry. Your job runner retries a failed request that actually succeeded, and the user gets two messages with different links. The idempotency_key above prevents it, and idempotency keys and retries explains the general pattern.
The screen after signup
The message the user sees while waiting does more work than the email does.
- Show the address you sent to, spelled out, with an edit control.
- Say how long it takes, and mean it.
- Tell them to check spam, because sometimes it is there.
- Put the resend button behind a short cooldown and show the countdown.
- Give a support route that is not another email address.
If it never arrives
Once the flow is right, delivery is the other half. Check the delivery events for the message id you stored, because the difference between "we never sent it" and "it bounced" is the difference between a code bug and a bad address.
curl -s https://emails.sh/v1/emails/em_2t9x4k1c7v \
-H "Authorization: Bearer $EMAILSSH_API_KEY"If it delivered and landed in spam, that is a reputation and authentication problem, covered in why your password reset email goes to spam and in SPF, DKIM, and DMARC explained for developers. If it bounced, it must go on the suppression list before you retry it forever, per bounces, complaints, and suppression lists.
Ready-made bodies for these messages are on the templates page. If an assistant scaffolded your signup flow, the same integration is written out for Lovable and Replit.
Questions
- How long should an email verification link last?
- Twenty-four hours is a reasonable default for verification, since people check mail on their own schedule. Password reset links should be much shorter, fifteen to sixty minutes, because they grant immediate account access.
- Should I use a code or a link?
- A link is fewer steps on desktop. A six-digit code is better on mobile, where switching apps loses context, and it is immune to link scanners consuming the token. Offering both in the same message is common and costs little.
- How do I stop email scanners from consuming verification links?
- Either put the state change behind a POST on a confirmation page, so a GET only renders a button, or make repeat visits idempotent so the human who arrives second still sees success. Do not treat an already-used token as a hard error.
- Should users be blocked from the app until they verify?
- Usually not. Let them in and gate the sensitive actions: inviting teammates, changing billing, or sending anything from your product. That converts better and reduces support load when a message is delayed.
Give your agent an address it can answer from.
Create an inbox