Magic links are having a moment. You click "sign in," check your email, click a link, and — poof — you're authenticated. No password, no TOTP app, no "your password must contain at least one hieroglyph." It feels magical.
But here's the uncomfortable truth: a magic link is only as magical as the code behind it. Done right, it's genuinely solid authentication. Done wrong, it's a single-use password reset flow with a snappier name and more attack surface. Let's break down what separates the real thing from the illusion.
How Magic Links Actually Work
The flow is deceptively simple:
- User submits their email.
- Server generates a cryptographically random token, stores it (usually hashed) alongside the user ID and an expiry timestamp.
- Server sends the token embedded in a URL to the user's email.
- User clicks the link; server validates the token, creates a session, and immediately invalidates the token.
Step 4 is the one most implementations mess up. More on that shortly.
The Token Generation Trap
The single biggest magic link mistake I've seen in the wild — and I mean in production code during audits at Cubet — is using predictable token sources.
Bad:
// Please don't do this
const token = crypto.createHash('md5')
.update(user.email + Date.now())
.digest('hex');
This is laughable. Date.now() has millisecond granularity. An attacker who knows roughly when you requested a link can brute-force the timestamp window. And MD5 is just adding insult to injury.
Good:
import { randomBytes } from 'crypto';
function generateMagicToken(): string {
// 32 bytes = 256 bits of entropy. Brute-force this in your spare time.
return randomBytes(32).toString('base64url');
}
// Store it hashed — raw token goes in the email, hash goes in the DB
import { createHash } from 'crypto';
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
You store the hash in the database, not the raw token. This way, if your database leaks, the tokens in it are useless. Same principle as password hashing — the database should never hold the secret that grants access.
The Five Failure Modes Nobody Talks About
1. Token Reuse (The Big One)
If your magic link can be clicked more than once, it's not a magic link — it's a long-lived session cookie delivered via email. The moment a token is used successfully, it must be deleted from the store. Immediately. Atomically. No "mark as used" flag that you check with a SELECT then UPDATE — do it in a single operation.
-- PostgreSQL: delete and return in one shot
DELETE FROM magic_tokens
WHERE token_hash = $1
AND expires_at > NOW()
RETURNING user_id, email;
-- If rows = 0, the token is invalid/expired/already used
2. Expiry That's Too Generous
A 24-hour magic link is basically a password that lives in an email inbox. Inboxes get compromised. Best practice is 15 minutes for most flows, up to an hour if your users are expected to be on bad connectivity. I've seen codebases with 7-day expiry — that's not a magic link, that's a standing invitation.
3. Email Forwarding and Referer Leakage
If your magic link URL contains the token as a query parameter (it usually does), that URL will land in server access logs on any redirect, and in the Referer header if the landing page loads third-party assets. Two mitigations:
- Use a redirect chain: the landing page at
/auth/verify?token=...consumes the token, creates a session cookie, then 302 redirects to the actual destination. The destination page never sees the token URL. - Set
Referrer-Policy: no-referreron your auth pages.
4. No Rate Limiting on the Request Endpoint
The "request a magic link" endpoint hits your email provider and creates a DB row per call. Without rate limiting, someone can spam it with a victim's email address, flooding their inbox and racking up your Mailgun bill simultaneously. Rate-limit by IP and by email address — both independently.
5. Account Enumeration Via Timing
This one is subtle. If you return "email not found" immediately when an address isn't registered, you've just built a user enumeration oracle. Always respond with the same message ("if that email is registered, you'll get a link") and process at constant time — or at least a constant minimum time using setTimeout to pad the fast path.
Is It More Secure Than Passwords?
For most apps targeting non-technical users: yes, actually. Here's why:
- No password reuse. Your breach doesn't become someone else's breach.
- No phishing-resistant? Not quite — but attackers have to control the user's email account to exploit a magic link. With passwords, all they need is the same password the user reused on a breached forum from 2013.
- The "forgot password" flow is the login flow. You've eliminated an entire separate attack surface.
The weaknesses are real but manageable: you're now dependent on email delivery reliability, and email accounts are a juicy target. For high-security apps (banking, healthcare), magic links alone aren't sufficient — pair them with a second factor or prefer hardware keys. For a SaaS dashboard? They're probably better than the password + optional TOTP setup most users never turn on anyway.
A Minimal Checklist
Before shipping your magic link implementation:
- Token is
crypto.randomBytes(32)or equivalent — never derived from timestamps or user data - Token hash stored in DB; raw token sent only in email
- Token expires in ≤ 15 minutes for standard flows
- Token is deleted (not flagged) on first use
- Request endpoint rate-limited by both IP and email
- Landing page redirects away from the token URL after consuming it
-
Referrer-Policy: no-referreron auth pages - Consistent response message regardless of whether email exists
The Bottom Line
Magic links aren't theater if you build them correctly. The "magic" isn't the UX gimmick — it's the shift of trust anchor from "something the user knows" (a password they've probably reused) to "something the user controls" (their email account). That's a legitimate security trade-off worth making for many applications.
The implementation is where it falls apart. A four-hour expiry, a reusable token, and a missing rate limit aren't magic — they're a vulnerability wearing a top hat.
Get the token generation right, make invalidation atomic, and keep expiry short. The rest is polish.
Have an opinion on magic links vs. passkeys vs. good old TOTP? I'm always up for an auth nerd fight — find me on Twitter/X or connect on LinkedIn. And if you've got a magic link implementation you want a second pair of eyes on, you know where to find me.