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.SB_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: trueto 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.