Magic links have surged in popularity as a passwordless login alternative: the user enters their email, receives a link, clicks it, and is authenticated. The experience is smoother than a password for most users, and there's no password database to breach. But the security model of a magic link is non-obvious, and several common implementation patterns introduce vulnerabilities that make magic links less secure than a properly implemented password flow.
Token entropy: how much is enough?
A magic link token is a bearer credential. Anyone who has the URL can log in as that user. The token therefore needs to be unguessable — statistically impossible to brute-force within its validity window.
128 bits of entropy is the minimum acceptable floor. At 15-minute expiry, an attacker making 1,000 requests per second has about 900,000 attempts. The probability of guessing a 128-bit random token in that window is vanishingly small (~2.6 × 10⁻³¹). Anything less needs justification.
import { randomBytes, createHash } from 'crypto';
function generateMagicLinkToken(): { raw: string; hash: string } {
// 20 bytes = 160 bits — comfortably above 128-bit minimum
const raw = randomBytes(20).toString('base64url'); // URL-safe, ~27 chars
const hash = createHash('sha256').update(raw).digest('hex');
return { raw, hash };
}
// Full URL: https://app.bastionary.com/auth/magic?token=RAW_TOKEN_HERE
// Store only the hash in the database
async function createMagicLinkToken(email: string, db: DB, redis: Redis): Promise<string> {
// Rate limit: max 5 magic links per email per 10 minutes
const rateLimitKey = `ml:rate:${email.toLowerCase()}`;
const count = await redis.incr(rateLimitKey);
if (count === 1) await redis.expire(rateLimitKey, 600);
if (count > 5) throw new RateLimitError('Too many magic link requests');
const user = await db.users.findByEmail(email);
if (!user) return; // return without error to prevent email enumeration
const { raw, hash } = generateMagicLinkToken();
// Invalidate any existing unexpired token for this user
await db.magicLinkTokens.invalidateForUser(user.id);
await db.magicLinkTokens.create({
userId: user.id,
tokenHash: hash,
expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15 minutes
usedAt: null,
createdForIp: null, // don't bind to IP — email may be opened on different device
});
return raw; // include in email URL
}
Single-use enforcement and the prefetch problem
The most critical property of a magic link is single-use. Once consumed, the token must be invalidated immediately. However, email clients and security scanners pre-fetch URLs, which can consume the token before the user ever sees it.
The fix is identical to the email verification case: use a two-step flow. The GET request loads a confirmation page; the actual token consumption happens on a POST:
// GET /auth/magic?token=...
// → Show "Click to sign in" page, don't consume token yet
router.get('/auth/magic', async (req, res) => {
const { token } = req.query as { token: string };
if (!token) return res.redirect('/login?error=missing_token');
// Lightly validate the token format without consuming it
const hash = createHash('sha256').update(token).digest('hex');
const record = await db.magicLinkTokens.findByHash(hash);
if (!record || record.usedAt || new Date() > record.expiresAt) {
return res.render('magic-link-error', {
message: 'This link has expired or already been used.',
});
}
// Render confirmation page — user must click a button
res.render('magic-link-confirm', {
token,
email: maskEmail(record.email),
});
});
// POST /auth/magic — actual token consumption
router.post('/auth/magic', async (req, res) => {
const { token } = req.body;
const hash = createHash('sha256').update(token).digest('hex');
const record = await db.magicLinkTokens.findAndConsume(hash);
if (!record) {
return res.status(400).render('magic-link-error', {
message: 'This link is no longer valid.',
});
}
// Issue session
const sessionId = await createSession(record.userId, req);
res.cookie('sid', sessionId, { httpOnly: true, secure: true, sameSite: 'lax' });
res.redirect(record.redirectTo || '/dashboard');
});
-- Atomic find-and-consume to prevent race conditions
CREATE FUNCTION consume_magic_link_token(p_hash CHAR(64))
RETURNS TABLE (user_id UUID, redirect_to TEXT) AS $$
DECLARE
v_record magic_link_tokens%ROWTYPE;
BEGIN
SELECT * INTO v_record
FROM magic_link_tokens
WHERE token_hash = p_hash
AND used_at IS NULL
AND expires_at > NOW()
FOR UPDATE SKIP LOCKED; -- prevent concurrent consumption
IF NOT FOUND THEN
RETURN;
END IF;
UPDATE magic_link_tokens
SET used_at = NOW()
WHERE id = v_record.id;
RETURN QUERY SELECT v_record.user_id, v_record.redirect_to;
END;
$$ LANGUAGE plpgsql;
Expiry: 15 minutes is the maximum
Email is not a secure channel. Email transit can be delayed; mailboxes can be compromised; corporate email forwarding can expose messages to additional parties. A 15-minute expiry limits the window during which a compromised email gives access to your application.
Some implementations use 1-hour or even 24-hour expiry for user experience reasons. The counterargument: if the user doesn't click a 15-minute link, they can simply request a new one. The UX cost of a second request is minimal; the security benefit is significant.
SameSite cookie and cross-device use
Magic links are often opened on different devices than where they were requested — a user requests the link on their laptop, then opens it on their phone. Don't use sameSite: 'strict' for the session cookie set immediately after magic link verification. Use lax — otherwise the redirect from the email client (a different origin) may not set the cookie correctly in some browsers.
Logging and monitoring
Log every magic link issuance and consumption with IP addresses, user agents, and timestamps. Anomalies to alert on:
- Token consumed from a different country than where it was requested
- Token consumed more than 5 minutes after a request from the same IP (suggests a delay/interception)
- Multiple users requesting magic links from the same IP in a short window (credential stuffing via magic link endpoint)
- Tokens that were generated but never consumed (abandoned logins — could indicate email delivery issues or OSINT fishing)