Use case
Magic-link sign-in
Send magic-link sign-in emails that resist replay, defend against link-preview prefetch, and survive cross-device flows where the click happens on a different machine than the sign-in.
Magic links are the cleanest auth UX a product can ship — one click and you are signed in. The implementation looks simple but has three subtle traps: link-preview prefetch (Outlook, Slack, iMessage will hit your URL before the user clicks), cross-device flows (request on a phone, click on a desktop), and the replay window that opens the moment the link leaves your sender. This page shows how to ship magic-link sign-in that handles all three correctly.
Anatomy
What every solid implementation has
- 1A request endpoint that takes an email and always responds 200 — even for unknown addresses, otherwise you have enabled enumeration.
- 2256+ bits of entropy in the token, generated server-side with crypto.randomBytes.
- 3A persisted row { token_hash, email, created_at, expires_at, consumed_at?, source_session? } with a 10-15 minute TTL.
- 4The link uses POST, not GET, OR the GET handler simply renders a "confirm sign-in" page — never auto-consumes the token on first hit, because Outlook / Slack / iMessage will prefetch the URL.
- 5Single-use deletion (or atomic consumed_at update) on actual sign-in confirmation.
- 6Optional but recommended: bind the token to the originating browser session so cross-device clicks become explicit, not silent.
Implementation
The actual code
Request the magic link
Always 200, always identical body. The created_at value lets you optionally pin tokens to the requesting browser (next recipe). Tokens are hashed before persisting so a DB read does not equal a takeover.
import { Sndr } from '@rkiza/sndr'; import { randomBytes, createHash } from 'node:crypto'; const sndr = new Sndr(process.env.SNDR_API_KEY!); export async function requestMagicLink( email: string, /** The current session id of the browser that requested the link. * Used downstream to detect cross-device clicks. */ requestingSessionId: string, ) { const user = await db.user.findUnique({ where: { email } }); if (user) { const raw = randomBytes(32).toString('base64url'); const hashed = createHash('sha256').update(raw).digest('hex'); await db.magicLink.create({ data: { token_hash: hashed, user_id: user.id, requesting_session_id: requestingSessionId, expires_at: new Date(Date.now() + 15 * 60_000), }, }); const link = `https://yourdomain.com/auth/magic-link?token=${raw}`; await sndr.emails.send( { from: 'auth@yourdomain.com', to: email, subject: 'Sign in to yourdomain', text: `Click here to sign in:\n\n${link}\n\nLink expires in 15 minutes. If you did not request this, ignore this email.`, html: `<p><a href="${link}">Click here to sign in</a>.</p><p>Link expires in 15 minutes. If you did not request this, ignore this email.</p>`, }, { idempotencyKey: `magic-${user.id}-${Math.floor(Date.now() / 900_000)}` }, ); } return { ok: true }; }Click-through: render confirmation, do not auto-consume
The GET handler renders a 'Confirm sign-in' button. Email-client prefetchers (Outlook Safe Links, Slack, iMessage Smart Links) hit the GET — if you auto-consume the token there, the user clicks an invalid link. The confirmation button POSTs to actually consume.
// GET handler — render the confirmation page, do nothing destructive. export async function showMagicLinkConfirmation(token: string) { if (!token) return { status: 404 }; // Render <form method="POST" action="/auth/magic-link/confirm"> // <input type="hidden" name="token" value="..." /> // <button>Sign in as alex@example.com</button> // </form> // Do NOT mutate any state here. return { status: 200 }; } // POST handler — atomic compare-and-consume. import { createHash } from 'node:crypto'; export async function consumeMagicLink( token: string, /** Current browser session id at the moment of the POST. */ consumingSessionId: string, ) { const hashed = createHash('sha256').update(token).digest('hex'); const row = await db.magicLink.findUnique({ where: { token_hash: hashed } }); if (!row || row.expires_at < new Date() || row.consumed_at) { throw new Error('invalid_or_expired_token'); } // Cross-device detection: warn (or require a step-up) when the click // happens in a different browser than the request. Many products // accept it silently; security-sensitive products require either // matching session or an additional factor. const isSameBrowser = row.requesting_session_id === consumingSessionId; await db.$transaction([ db.magicLink.update({ where: { id: row.id }, data: { consumed_at: new Date() }, }), db.session.create({ data: { user_id: row.user_id, // Mark cross-device sign-ins so audit log + UI can warn. cross_device: !isSameBrowser, }, }), ]); return { user_id: row.user_id, cross_device: !isSameBrowser }; }
Production considerations
What you have to get right
Link prefetch is real
Outlook Safe Links, Slack Unfurls, iMessage Smart Links, and corporate proxy scanners all fetch URLs in emails before the user clicks them. Never put destructive actions on GET — render a confirmation that requires a POST.
Cross-device flows
Phone-request + desktop-click is a normal pattern. Decide your stance: silently accept (best UX, slightly weaker security), warn the user (recommended default), or require a matching session (highest security, lowest UX).
Single-use, always
Atomic consume on sign-in success. A reused link is either a replay attack or a confused user — either way, the second use should fail closed.
TTL discipline
10-15 minutes balances "user got distracted, came back" against "leaked email gives a multi-hour window". 24-hour links are common but materially weaker.
Never echo email in error pages
A failed click should redirect to a generic "this link has expired" page that does not reveal whether the email had ever been issued one. Same enumeration concern as password reset.
Deliverability for auth mail
Magic links are blocking — if they do not arrive, the user cannot sign in. Use a dedicated auth subdomain, monitor inbox rate per ISP, and run an alerting channel on sustained drops.
FAQ
Why a confirmation page instead of immediate sign-in on click?
Email-client link-preview fetchers will hit your URL automatically. Without a confirmation step, the prefetcher silently consumes the user's token — the real click then gets an "invalid token" error.
How long should the link live?
10-15 minutes is our default. Shorter (5 minutes) is acceptable if your audience checks email frequently. Longer (24 hours) materially weakens security; if you need 24-hour resilience consider regenerating a fresh token on each retry-from-error rather than extending the original TTL.
Should I show the user's name in the confirmation page?
Only the email portion they typed. Anything beyond that leaks whether the account exists. The confirmation page should look identical whether the account is real or not.
Can magic links replace passwords entirely?
For consumer SaaS, often yes. For B2B with SSO it complements rather than replaces. For high-stakes accounts (admin panels, payments) require an additional factor at the consume step.
Related
Ship this in production today.
Free tier includes 3,000 emails / month. No card required.
Start building
Your next message starts here.
Connect your domain, create an API key, and send with SNDR.