auth · Lesson 8 · Node + Express

Passwordless: Magic Links & OTP

"No password" sounds like less security, but it's often more — there's no reusable secret to phish, leak, or reuse across sites. Magic links and one-time codes both rest on a single move you've already seen twice: a short-lived, single-use secret sent to something you already control (your inbox). Today you'll build both and watch every attack against them bounce off.

🎯 Mission: a mental model of modern auth + the ability to wire it into a real app
The one idea

Passwordless replaces "something you remember" with "something you can receive." Instead of checking a stored secret, the app sends a fresh, expiring, single-use token to a channel you own (email) and logs you in if you can produce it. The token is the whole credential — so it must be high-entropy, time-limited, single-use, and stored only as a hash (Lesson 4) — exactly like the OAuth code (Lesson 5) was.

01 Why go passwordless at all

Lesson 4 showed how much can go wrong with passwords even when you do everything right: users reuse them across sites (one breach cascades), they get phished, and you carry the liability of a hashed-password store forever. Passwordless sidesteps the root cause — there's no long-lived shared secret to steal.

💡 The mental shift: proof of control, not proof of memory A password proves you remember a secret. A magic link / OTP proves you control an account (your email) the app already trusts to identify you. You're outsourcing identity to a channel the user already secures — which is also the method's main weakness: it's only as strong as that inbox. See Authgear — passwordless overview.

02 The flow (it's one shape for both)

Magic link and OTP are the same protocol with a different delivery format — a clickable URL vs a typed code:

You App Your inbox ① "log me in: you@example.com" make token, store HASH ② email the RAW token (link or code) ③ you fetch it from your inbox ④ present token → app verifies hash, burns it → start a session (L2)
The token in your inbox is the credential. Step ④ verifies it against a stored hash and immediately burns it — then hands off to a normal session (Lesson 2), like every login in this course.

Notice the recurring spine: the method ends at "start a session." Passwordless is a phase-① login that, like all the others, hands off to phase ③.

03 The four non-negotiable properties

The token is the whole credential, so it has to be airtight. Each property reuses something you already learned:

High-entropy

≥128 bits of randomness for a link (e.g. randomBytes(32)) — unguessable. (OTP gets away with 6 digits only because of the next two.)

Time-limited

Expire in ~15 minutes. Shrinks the window if the email leaks or sits in an inbox. (Like the OAuth code, Lesson 5.)

Single-use

Burn it the instant it's redeemed. A used link/code is dead — so interception-after-use is worthless.

Stored as a hash

Save sha256(token), never the token. A DB leak then can't be used to log in. (Exactly Lesson 4's reasoning.)

⚠️ OTP needs one more thing: rate limiting A 6-digit code is only 1,000,000 possibilities — brute-forceable in seconds without a guess limit. So OTP must lock out after a handful of wrong attempts. With rate-limit + 15-min expiry, a tiny code space is fine; without them, it's wide open. (Magic-link tokens are huge, so they don't need this.)

04 🎯 Your tangible win (5 min): build both, attack both

A zero-dependency lab issues a magic link and an OTP, logs in with each, then throws every attack at them — reuse, forgery, expiry, brute force.

# in your terminal (no npm install needed):
cd ~/projects/learn/public/courses/auth/practice/passwordless-lab
node demo.js
🎯 The win — the defenses firing, verbatim
magic link — every attack bounces:
  click the SAME link again → 🛡️ "token already used (single-use)"
  forged/guessed token     → 🛡️ "invalid token"
  clicked 16 min later     → 🛡️ "token expired"

OTP brute force — 6 digits, but:
  guess #5 → 🛡️ wrong code
  guess #6 → 🛡️ too many attempts — locked
You just built both passwordless methods from primitives you already knew (hash, expiry, single-use, rate-limit) and watched the four properties block four different attacks. Open demo.js: the magic-link and OTP code paths are nearly identical — proof they're one idea in two costumes.

05 Magic link vs OTP — when to pick which

Magic linkOTP (emailed code)
User actionClick a linkType a 6-digit code
Cross-deviceAwkward — link opens on the email's deviceEasy — read on phone, type on laptop
EntropyHuge (the token is the secret)Tiny — leans on rate-limit + expiry
Good as a 2nd factor?Not reallyYes — a true step-up factor
Main riskLink forwarded / email security; link-scanners pre-fetching itPhishable (user can be tricked into reading it aloud)
⚠️ The magic-link gotcha nobody warns you about Corporate email security scanners and chat apps pre-fetch links to check them — which can silently consume a single-use magic link before the user clicks, or worse, log them in. Mitigations: require a POST (a confirm button on the landing page) rather than logging in on the bare GET, and bind the link to the requesting browser/session where possible. See SuperTokens — magic links.

06 The honest limitations

Passwordless isn't magic. Two real caveats to say out loud:

"It's unphishable"False for links & OTP. An attacker can run a fake login that triggers a real code, then relay what you type/click. Email/OTP are phishing-resistant-ish, not phishing-proof. Only passkeys (Lesson 9) truly close this.
"SMS is fine for codes"Avoid SMS. The FBI & CISA issued 2025 guidance against SMS-based auth (SIM-swap, interception); regulators are phasing out SMS OTP in finance. Prefer email or an authenticator app.
💡 Inbox = master key With email magic links/OTP, whoever controls the inbox controls every account that uses it for login. That's usually fine (it's already true for "forgot password"), but it means your auth is only as strong as the user's email security — worth stating in any threat model. See Security Boulevard — passwordless 101.

07 Check yourself

Q1. A 6-digit OTP has only a million possibilities. Why isn't it trivially brute-forced?

Show answer

Two guards make the small space safe: rate limiting (lock out after ~5 wrong tries — you watched guess #6 get locked) and short expiry (the code dies in ~15 min). An attacker gets a handful of guesses against a million-space target before lockout, then the code is gone. Rate-limit + expiry beat raw length. (§03 warning, §04.)

Q2. Why store only a hash of the magic-link token, when the token is already random and short-lived?

Show answer

Same reason as passwords (Lesson 4): if your database leaks, plaintext tokens would let the attacker log in as any pending user during the validity window. Storing sha256(token) means a DB dump is useless for login — the attacker can't reverse the hash to the raw token the inbox holds. Defense for the "assume breach" case. (§03.)

Q3. Your magic links sometimes "expire before the user clicks them," and login emails show as already-read by a security appliance. What's happening and one fix?

Show answer

A corporate email scanner / link-preview bot is pre-fetching the link, consuming the single-use token (or even completing login) before the human clicks. Fix: don't log in on the bare GET — land on a page with a "Confirm sign-in" button that POSTs, and/or bind the link to the originating session/device. (§05 warning.)

08 Where we go next

Passwordless removed the stored secret — but magic links and OTP still aren't truly phishing-proof, because a human can be tricked into relaying the token to a fake site. Lesson 9 is the endgame that closes that last hole: passkeys (WebAuthn / FIDO2). They use public-key cryptography bound to the real website's origin, so there's nothing to type, nothing to relay, and a phishing site simply can't get a usable signature. You'll generate a real key pair and watch a phishing attempt fail by construction.

📌 Remember this one line Passwordless = a high-entropy, time-limited, single-use token (stored as a hash) sent to a channel you control. Magic link and OTP are the same idea; OTP additionally needs rate limiting. Strength = your inbox's strength — and neither is fully phishing-proof.

💬 Ask me anything — e.g. "wire a magic link with real email (Nodemailer) in Express", "add the POST-confirm landing page", or "combine OTP as a second factor on top of passwords?" · Back to Lesson 7 · Lesson 4 · Resources.