Security Considerations
Email is the weakest link
Magic link security is bounded by email security. If the user’s email is compromised, magic link login is compromised. This is different from password + 2FA, where compromising one factor is not enough.
Specific risks:
Email interception. Email travels between mail servers in cleartext (unless both sides support TLS). An attacker on the network could intercept the magic link.
Compromised email account. If the attacker has access to the user’s email (phished credentials, session hijacking, shared device), they can request and use magic links.
Email forwarding. If the user has set up email forwarding (or an attacker sets it up), magic links are forwarded to the attacker.
Shared devices. If the user checks email on a shared computer, the magic link is in the browser history.
Mitigations we already built
Short expiry (15 minutes). Limits the window for interception.
Single-use tokens. The link works once. Even if intercepted, the attacker must use it before the legitimate user does.
Token hashing. A database breach does not expose usable tokens.
Previous link invalidation. Only the latest link works. If the user requests a new one (because they suspect the first was compromised), the old one is invalidated.
Additional mitigations
Rate limiting
Prevent an attacker from flooding a user’s inbox:
import { rateLimit } from "../rate-limit.js";
// In the magic link request handler:
const limit = rateLimit(`magic:${email}`, 3, 15 * 60 * 1000); // 3 per 15 minutes
if (!limit.allowed) {
// Still return the same response to prevent enumeration
return Response.json({
message: "If an account with that email exists, a login link has been sent.",
});
} Device binding (optional)
Bind the magic link to the device that requested it. Store a fingerprint (IP address, user agent) when the link is created, and check it when the link is used:
// At creation:
db.prepare("INSERT INTO magic_links (..., request_ip) VALUES (..., ?)").run(getClientIp(request));
// At verification:
if (row.request_ip !== getClientIp(request)) {
// Different device — could be legitimate (phone to desktop) or an attack
// Log and decide based on your threat model
} This is a soft signal, not a hard block. Users often request magic links on their phone and click them on their desktop (or vice versa). Blocking cross-device use hurts usability.
Login notification
After a successful magic link login, send a notification email: “You just logged in from [IP/location]. If this was not you, change your password immediately.”
This does not prevent the attack but helps the user detect it.
When to use magic links
Use them when: Your users prioritize convenience over security. Your app handles non-sensitive data. You want to reduce password-related support requests. You offer magic links alongside passwords (users choose).
Avoid them when: Your app handles sensitive financial, medical, or government data. Your users are high-value targets (executives, public figures). Email security in your user base is poor (shared email accounts, no 2FA on email).
Combine them with 2FA when: You want passwordless convenience with a second factor. The user clicks the magic link (email = first factor) and enters a TOTP code (authenticator = second factor). This is stronger than password + TOTP because the magic link token is random and single-use, unlike a reusable password.
Exercises
Exercise 1: Add rate limiting to the magic link request endpoint. Send 4 requests in rapid succession. The first 3 should work; the 4th should be silently rate-limited (same response, but no email sent).
Exercise 2: Add a login notification: after a successful magic link login, log a “magic_link_login” event with the email and IP.
Exercise 3: Think about your own app. Would magic links be appropriate? What is the sensitivity of the data?
Why is magic link login less secure than password + TOTP?