Email verification that doesn't frustrate users: time-limited tokens and magic links

Email verification is one of those flows that looks trivially simple and hides real complexity. The naïve implementation — generate a random token, store it, email it, check it — has multiple failure modes: tokens in logs, expired tokens with no resend UX, race conditions on verification, and email clients that pre-fetch URLs and consume one-time tokens before the user ever sees them. This post builds a correct implementation from scratch.

Two approaches: HMAC tokens vs stored random tokens

There are two fundamentally different architectures for verification tokens:

Stored random tokens: Generate random bytes, store the hash in your database alongside the user ID and expiry, send the raw token in the email. On verification, hash the received token and look it up.

HMAC-signed tokens: Generate a token that encodes the user ID and expiry, signed with a server secret. No database storage needed — the signature proves validity. On verification, re-derive the HMAC and compare.

Both are valid. HMAC tokens have the advantage of not requiring a database roundtrip on verification and no cleanup job for expired tokens. The tradeoff: if your signing secret is compromised, all outstanding tokens are compromised. For most applications, stored tokens are simpler and the correct default.

Stored random token implementation

import { randomBytes, createHash } from 'crypto';

const TOKEN_EXPIRY_MINUTES = 60; // 1 hour for email verification (magic links use 15min)

async function createEmailVerificationToken(userId: string, db: DB): Promise<string> {
  // 32 bytes = 256 bits of entropy (more than enough)
  const rawToken = randomBytes(32).toString('hex');
  const tokenHash = createHash('sha256').update(rawToken).digest('hex');

  // Invalidate any existing tokens for this user (prevent token accumulation)
  await db.emailVerificationTokens.deleteByUserId(userId);

  await db.emailVerificationTokens.create({
    userId,
    tokenHash,
    expiresAt: new Date(Date.now() + TOKEN_EXPIRY_MINUTES * 60 * 1000),
    usedAt: null,
  });

  return rawToken; // send this in the email, never store it
}

async function verifyEmailToken(rawToken: string, db: DB): Promise<{ userId: string } | null> {
  const tokenHash = createHash('sha256').update(rawToken).digest('hex');
  const record = await db.emailVerificationTokens.findByHash(tokenHash);

  if (!record) return null;
  if (record.usedAt) return null; // already used
  if (new Date() > record.expiresAt) return null;

  // Mark as used atomically with the user update
  await db.transaction(async (trx) => {
    await trx.emailVerificationTokens.markUsed(record.id);
    await trx.users.markEmailVerified(record.userId);
  });

  return { userId: record.userId };
}

HMAC-signed tokens (stateless approach)

import { createHmac, timingSafeEqual } from 'crypto';

const SIGNING_SECRET = process.env.EMAIL_TOKEN_SECRET!;

function createHmacToken(userId: string, expiresAt: number, purpose: string): string {
  const payload = `${userId}:${expiresAt}:${purpose}`;
  const signature = createHmac('sha256', SIGNING_SECRET)
    .update(payload)
    .digest('base64url');
  // Encode payload + signature as a URL-safe string
  const encoded = Buffer.from(payload).toString('base64url');
  return `${encoded}.${signature}`;
}

function verifyHmacToken(token: string, expectedPurpose: string): { userId: string } | null {
  const dotIndex = token.lastIndexOf('.');
  if (dotIndex === -1) return null;

  const encoded = token.slice(0, dotIndex);
  const receivedSig = token.slice(dotIndex + 1);

  let payload: string;
  try {
    payload = Buffer.from(encoded, 'base64url').toString('utf8');
  } catch {
    return null;
  }

  const [userId, expiresAtStr, purpose] = payload.split(':');
  if (!userId || !expiresAtStr || !purpose) return null;
  if (purpose !== expectedPurpose) return null;

  const expiresAt = parseInt(expiresAtStr, 10);
  if (Date.now() > expiresAt) return null;

  // Verify signature
  const expectedSig = createHmac('sha256', SIGNING_SECRET)
    .update(payload)
    .digest('base64url');
  const a = Buffer.from(expectedSig);
  const b = Buffer.from(receivedSig);
  if (a.length !== b.length || !timingSafeEqual(a, b)) return null;

  return { userId };
}

// Usage:
const token = createHmacToken(userId, Date.now() + 60 * 60 * 1000, 'email_verify');
const result = verifyHmacToken(token, 'email_verify');

The email prefetch problem

Email security proxies and some email clients will GET every URL in an email to scan for malicious content or generate link previews. If your verification link is a one-time-use URL, the proxy will consume it before the user ever opens the email. This is a real production problem.

The fix: make the verification link navigate to a confirmation page that requires a POST action (a button click) to actually verify. The GET request just loads the UI; the POST consumes the token:

// GET /auth/verify-email?token=xxx
// → Show a "Confirm your email" page with a button
router.get('/auth/verify-email', async (req, res) => {
  const { token } = req.query;
  // Don't verify yet — just render the page
  res.render('verify-email-confirm', { token });
});

// POST /auth/verify-email (form submission from the button)
router.post('/auth/verify-email', async (req, res) => {
  const { token } = req.body;
  const result = await verifyEmailToken(token, db);

  if (!result) {
    return res.render('verify-email-error', {
      message: 'This link has expired or already been used.',
      showResend: true,
    });
  }

  // Success — set session and redirect
  req.session.userId = result.userId;
  res.redirect('/dashboard');
});

Magic links: UX considerations

Magic links replace the password entirely — the user enters their email, receives a link, clicks it, and is logged in. The security properties differ from verification tokens:

  • Expiry should be shorter: 15 minutes maximum, not 1 hour
  • The link must be strictly single-use — replay means account takeover
  • Rate limiting is critical — an attacker can spam magic link requests to the victim's email
// Rate limit magic link requests per email address
async function requestMagicLink(email: string, redis: Redis, db: DB): Promise<void> {
  const rateLimitKey = `magic_link:rate:${email.toLowerCase()}`;
  const count = await redis.incr(rateLimitKey);
  if (count === 1) await redis.expire(rateLimitKey, 600); // 10-minute window
  if (count > 5) throw new RateLimitError('Too many magic link requests');

  const user = await db.users.findByEmail(email);
  if (!user) {
    // Don't reveal whether the email exists — return success either way
    return;
  }

  const token = await createEmailVerificationToken(user.id, db, 15); // 15 min
  await sendMagicLinkEmail(user.email, token);
}

The resend UX and rate limiting

When a verification token expires, the user needs a frictionless way to get a new one. Show the resend button prominently on the expired token error page. Rate limit resends at 3 per hour per email address — enough for legitimate use, too slow for abuse.

Always confirm the email address in the resend UI: "We'll resend to james@example.com." This gives users a chance to spot typos and corrects the single largest category of "I never got the email" support tickets.

For verification emails, always check your spam deliverability. Test with mail-tester.com before launch. A verification email that lands in spam silently blocks all new registrations — you'll see signup rates drop to near zero with no obvious error in your logs.