Recipe

Magic-link login

Passwordless auth. User enters their email, you send a one-time click-to-login URL. Single-use, short-lived, HMAC-signed.

Why magic-link instead of password

  • No password reset flow to maintain
  • No password to leak in a future breach
  • Higher conversion on signup (one step fewer)
  • SMS/Authenticator-app 2FA still works on top

Anatomy of a magic-link token

Generate server-side. Include enough state that the link is self-validating:

token = base64url(json({
  user_id:   "u_abc123",
  email:     "alice@acme.com",   // re-checked at consume time
  issued_at: 1731600000,
  expires_at: 1731601800,         // 30 minutes
  nonce:     "<random-128-bit>",
  purpose:   "login"
})) + "." + hmac_sha256(secret, payload)

Send

// app/api/auth/magic-link/route.ts
import { sendTransactional } from "@/lib/sendbolt";

export async function POST(req: Request) {
  const { email } = await req.json();

  // Quietly handle non-existent emails — don't leak account existence
  const user = await db.users.findByEmail(email);
  if (user) {
    const token = signMagicLinkToken({ userId: user.id, email });
    const loginURL = `https://acme.com/auth/consume?t=${token}`;

    void sendTransactional({
      to: email,
      templateID: process.env.MP_TEMPLATE_MAGIC_LINK!,
      vars: {
        FirstName: user.firstName ?? "there",
        LoginURL: loginURL,
        ExpiryMinutes: "30",
      },
      bounceRiskCheck: true,
    });
  }

  // Always return success — don't let attackers enumerate accounts
  return Response.json({ ok: true, message: "Check your email." });
}

Consume

// app/auth/consume/route.ts
export async function GET(req: Request) {
  const token = new URL(req.url).searchParams.get("t");
  if (!token) return Response.redirect("/login?err=missing", 302);

  let claims;
  try {
    claims = verifyMagicLinkToken(token);  // checks HMAC + expiry
  } catch (e) {
    return Response.redirect("/login?err=invalid", 302);
  }

  // Single-use: atomic INSERT into consumed_tokens. If the row already exists,
  // the constraint violation means someone (or the email forwarder) already used it.
  try {
    await db.consumedTokens.insert({ nonce: claims.nonce, userId: claims.user_id });
  } catch (e) {
    return Response.redirect("/login?err=replayed", 302);
  }

  // Re-verify the email still matches the user (covers email-change race)
  const user = await db.users.findById(claims.user_id);
  if (!user || user.email !== claims.email) {
    return Response.redirect("/login?err=stale", 302);
  }

  await issueSessionCookie(user.id);  // 90-day rolling
  return Response.redirect("/dashboard", 302);
}

UX patterns

  • 30-minute expiry — long enough for users who go grab coffee, short enough that a forgotten tab isn't a security hole
  • “Resend” with cooldown — disable for 60 seconds after first send to prevent abuse + duplicate emails
  • Show the email address on the “check your email” screen — typos are the #1 cause of magic-link failures
  • Detect cross-device click — if the link is clicked from an IP/UA that doesn't match the request origin, show a confirmation page rather than silently logging in

Reputation considerations

Magic-link emails are deeply 1:1 — high engagement, fast clicks, no unsub. Gmail loves them. But:

  • If a user enters a typo'd email, you waste a send and (worse) generate an open-rate-of-zero data point. Use bounce_risk_check_enabled: true to catch disposable domains before they hit your funnel.
  • Don't include “If you didn't request this, ignore it” in the SUBJECT — that phrasing is a classic spam-filter signal. Put it in the body, not the subject.
  • One-line body wins— “Click here to sign in to Acme.” A short magic-link email signals “real human interaction” to filters.

Don't do

  • Reuse the same magic-link token if the user requests two in a row — issue a fresh one and invalidate the old one
  • Embed the user's session secret in the URL — even a signed token shouldn't carry your JWT's signing key
  • Skip the HMAC and rely on guessing a random ID being hard — random IDs leak via referrers, logs, and screenshare

See also: Password reset uses the same token machinery. If you ship both, share the signing / consume code.