patterntypescriptMajor
Magic Link Authentication Flow
Viewed 0 times
magic linkpasswordlesstoken hashcrypto.randomBytesemail loginsingle-use token
Problem
Implementing passwordless magic-link authentication requires careful token generation, storage, expiry enforcement, and single-use invalidation to prevent replay attacks.
Solution
Generate a cryptographically random token, store its hash in the database with an expiry timestamp, email the raw token as a URL parameter. On verification, hash the received token, look it up, check expiry, then immediately delete it.
Why
Storing the hash (not the raw token) means a database breach does not expose valid login links. Single-use deletion prevents an intercepted link from being replayed. Short expiry (15 min) limits the attack window.
Gotchas
- Use crypto.randomBytes(32).toString('hex') — not Math.random() which is not cryptographically secure
- Store a SHA-256 hash of the token, not the token itself
- Rate-limit magic link requests per email to prevent email flooding
- Tokens emailed via redirect links (e.g. email tracking wrappers) can leak in server logs — use POST-to-redirect pattern on landing
Code Snippets
Magic link generation and verification
import crypto from 'crypto';
// --- Generation ---
export async function sendMagicLink(email: string) {
const rawToken = crypto.randomBytes(32).toString('hex');
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
await db.magicLink.upsert({
where: { email },
create: { email, tokenHash, expiresAt },
update: { tokenHash, expiresAt },
});
const link = `${process.env.APP_URL}/auth/verify?token=${rawToken}&email=${encodeURIComponent(email)}`;
await sendEmail({ to: email, subject: 'Your login link', body: link });
}
// --- Verification ---
export async function verifyMagicLink(rawToken: string, email: string) {
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
const record = await db.magicLink.findUnique({ where: { email } });
if (!record || record.tokenHash !== tokenHash || record.expiresAt < new Date()) {
throw new Error('Invalid or expired link');
}
// Single-use: delete immediately
await db.magicLink.delete({ where: { email } });
return db.user.findUniqueOrThrow({ where: { email } });
}Revisions (0)
No revisions yet.