Magic Links and Email OTP
Passwordless login by email — genuinely simpler for users, with a short list of ways to get it wrong.
A magic link replaces the password with proof of email control: enter an address, receive a link, click it, you are in. Email OTP is the same idea with a typed code instead of a link — better on mobile, where the link often opens in the wrong browser.
What it trades
- ✅ No password to forget, reuse, or have breached.
- ✅ No password hashing, reset flow, or strength policy to build.
- ❌ Your security is now your email provider's security. Anyone with mailbox access has your account.
- ❌ Slower login, and it fails when mail is delayed or filtered.
- ❌ Phishable — a convincing page can ask for the code and relay it.
The rules
- Store a hash of the token, never the token. Same reasoning as passwords.
- Single use. Mark consumed atomically, or a forwarded email logs two people in.
- Short expiry — 10 to 15 minutes.
- Bind to the requesting browser where you can: store a marker at request time and require it at redemption. This blunts the attack where a victim is talked into forwarding a link.
- Never reveal whether the address exists. Always answer "if that address is registered, we have sent a link."
- Rate-limit per address and per IP, or you have built an email bomber.
- Beware link scanners. Corporate mail security fetches every URL in an email — a
GETthat consumes the token means the user's link is dead on arrival. Land on a page with a confirm button thatPOSTs.
Which to choose
Use a code when the user is likely on mobile or in an app: it can be typed into the session that requested it, which sidesteps the cross-browser problem entirely. Use a link on desktop for the lower friction. Offering both from the same email is common and cheap.
Example
// Request — always the same answer, whether or not the account exists
app.post('/auth/magic-link', magicLimiter, async (req, res) => {
const email = String(req.body.email || '').toLowerCase().trim();
const user = await db.users.findByEmail(email);
if (user) {
const token = randomBytes(32).toString('base64url');
await db.loginTokens.insert({
hash: sha256(token),
userId: user.id,
expiresAt: new Date(Date.now() + 15 * 60e3),
requestIp: req.ip,
});
await sendMail(email, `https://abc.com/auth/verify?token=${token}`);
}
// Identical response and timing either way — no user enumeration.
res.json({ message: 'If that address is registered, we have sent a link.' });
});When to use it
- A B2B tool drops passwords entirely, since every user already has a corporate mailbox protected by their company's SSO.
- A mobile app sends a six-digit code instead of a link so the code can be typed into the app that requested it.
- A team switches the verification page to a POST-on-click after discovering corporate link scanners were consuming tokens before users saw the email.
More examples
Redemption that survives link scanners
Doing the check and the consume in one UPDATE ... RETURNING closes the race where two clicks a millisecond apart both pass a separate SELECT.
import { randomBytes, createHash } from 'crypto';
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
// GET: render a page. Consume NOTHING — a scanner may be doing this.
app.get('/auth/verify', (req, res) => {
res.send(`
<h1>Confirm sign in</h1>
<form method="POST" action="/auth/verify">
<input type="hidden" name="token" value="${escapeHtml(req.query.token)}">
<button type="submit">Sign me in</button>
</form>`);
});
// POST: the actual redemption, and it must be atomic.
app.post('/auth/verify', async (req, res) => {
const token = String(req.body.token || '');
// One statement: find AND consume. Two concurrent clicks cannot both win.
const row = await db.loginTokens.consumeIfValid({
hash: sha256(token),
now: new Date(),
});
if (!row) {
return res.status(400).render('login', {
error: 'That link has expired or was already used. Request a new one.',
});
}
// Optional but valuable: require the same browser that asked for the link.
if (row.browserMarker && req.cookies.login_marker !== row.browserMarker) {
return res.status(400).render('login', {
error: 'Please open this link in the browser where you requested it.',
});
}
await db.users.markEmailVerified(row.userId);
res.cookie('sid', await createSession(row.userId, req), COOKIE_OPTIONS);
res.redirect('/dashboard');
});
// SQL behind consumeIfValid — the atomicity matters more than it looks
// UPDATE login_tokens SET used_at = now()
// WHERE hash = $1 AND used_at IS NULL AND expires_at > now()
// RETURNING user_id, browser_marker;Email OTP, for when the link is the problem
crypto.randomInt(0, 1_000_000) avoids the modulo bias you get from randomBytes % 1000000, which subtly favours some codes over others.
// A code is typed into the SAME session that requested it, which removes the
// wrong-browser problem entirely — the usual reason magic links frustrate users.
app.post('/auth/otp/request', otpLimiter, async (req, res) => {
const email = String(req.body.email || '').toLowerCase().trim();
const flowId = randomBytes(16).toString('base64url');
const user = await db.users.findByEmail(email);
if (user) {
// 6 digits, uniformly distributed (no modulo bias)
const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, '0');
await redis.set(`otp:${flowId}`, JSON.stringify({
userId: user.id, hash: sha256(code), attempts: 0,
}), 'EX', 600);
await sendMail(email, `Your sign-in code is ${code}. It expires in 10 minutes.`);
} else {
await redis.set(`otp:${flowId}`, JSON.stringify({ userId: null }), 'EX', 600);
}
res.json({ flowId }); // same shape whether or not the user exists
});
app.post('/auth/otp/verify', otpLimiter, async (req, res) => {
const key = `otp:${req.body.flowId}`;
const raw = await redis.get(key);
if (!raw) return res.status(400).json({ error: 'expired' });
const flow = JSON.parse(raw);
// Cap attempts per flow: 6 digits is only a million, and 10 tries is plenty.
if (++flow.attempts > 5) { await redis.del(key); return res.status(429).json({ error: 'too_many_attempts' }); }
await redis.set(key, JSON.stringify(flow), 'KEEPTTL');
if (!flow.userId || flow.hash !== sha256(String(req.body.code))) {
return res.status(400).json({ error: 'invalid_code' });
}
await redis.del(key);
res.cookie('sid', await createSession(flow.userId, req), COOKIE_OPTIONS);
res.status(204).end();
});
Discussion