Magic Links and Passwordless
Here is a thing most teams never notice about their own login screen. The “forgot password” flow is already a passwordless login. You type your email, we send you a link, you click it, and you are in, holding a fresh session, about to set a new password you will forget again in three months.
If you trust that email enough to let it reset a password, you trust it enough to log someone in. So drop the password. Keep the email link, end it at the app instead of at a password form, and you have a magic link.
No password means no password to store, no password to leak, no password to reuse across forty sites, no “must contain one uppercase and a hieroglyph” theatre. For a lot of products, especially ones people use every few weeks, that is the right call. It is not free. The link becomes a bearer token flying through email, and email is a swamp. Get the details wrong and you have shipped a login that anyone who reads your database can use, or an endpoint that spams your users on command.
This article is about getting the details right, and about where the two cousins of the magic link, one-time codes and passkeys, fit in 2026.
The shape of the flow
Strip it to the bones. A magic link login is six moves.
Every one of those steps has a way to do it that looks fine in a demo and falls over in production. Let me take them in the order they bite.
Step 2: the token itself
The token is a bearer credential. Whoever holds it becomes the user, no questions asked. So it has exactly one job: be impossible to guess. That means real randomness from a cryptographically secure source, and enough of it that guessing is off the table.
Thirty-two bytes from your platform CSPRNG is the boring, correct answer. That is 256 bits of entropy, which no one is brute-forcing before the heat death of the sun, let alone before your short expiry kicks in.
import { randomBytes } from "node:crypto";
// 32 random bytes -> URL-safe string, no padding characters to escape
function mintToken() {
return randomBytes(32).toString("base64url");
}
base64url matters here. It uses - and _ instead of + and / and drops the = padding, so the token drops straight into a URL with nothing to percent-encode. (If you want the mechanics of query strings and encoding, see the URL object.)
What you must not do is get clever. No Date.now() seeds, no incrementing IDs, no Math.random(). Math.random() is not cryptographic; its output is predictable enough that a motivated attacker can reconstruct the stream. Reach for crypto, always.
Step 2, still: hash it before it touches the database
Now the part people skip. Do not store the token. Store a hash of it.
Think about what a magic link token actually is while it is valid: a working login sitting in a row. If your login_tokens table holds raw tokens and someone gets a read on that table, through SQL injection, a leaked backup, an over-permissioned analytics replica, they do not get “hashes to crack.” They get a bag of live logins. They click, and they are your users.
Hash the token, store the hash, and a leaked row is inert. The attacker sees a 256-bit digest and has no way back to the token that produces it, so no way to build a link that passes.
Here is the part that trips people who just read the password hashing article: you do not want argon2 or bcrypt here. Use a plain fast hash, SHA-256.
That sounds backwards until you remember why passwords need slow hashes. Passwords are low-entropy. People pick hunter2 and summer2026, so an attacker with the hashes runs billions of guesses a second and finds the weak ones. The slow, memory-hard hash exists to make each guess expensive.
Your token is not low-entropy. It is 256 bits of uniform randomness. There is no dictionary, no “common tokens” list, nothing to guess. The entropy is the defense. A slow hash would buy you nothing and cost you latency on every login. For high-entropy secrets, a fast hash is exactly right.
import { createHash } from "node:crypto";
function hashToken(token) {
return createHash("sha256").update(token).digest(); // Buffer
}
So the mint-and-store step, end to end:
async function sendMagicLink(db, email, mailer) {
const user = await db.userByEmail(email); // may be null; see enumeration below
const token = mintToken();
await db.query(
`INSERT INTO login_tokens (token_hash, user_id, expires_at)
VALUES ($1, $2, now() + interval '15 minutes')`,
[hashToken(token), user?.id ?? null]
);
const link = `https://app.example.com/callback?token=${token}`;
await mailer.send(email, "Your sign-in link", link);
}
Notice the raw token goes in the email and nowhere else. The hash goes in the row. The two never meet again until a click.
Step 3: a short TTL
That interval '15 minutes' is not decoration. A magic link is a live credential sitting in an inbox, and inboxes get breached, forwarded, screenshotted, and synced to laptops that get stolen. The shorter the window, the smaller the target.
Fifteen minutes is the common default and a reasonable one. Ten is fine. Five if you are guarding something sensitive. Much shorter and you start punishing the person whose mail provider took ninety seconds to deliver, or who got up to make coffee. Store expires_at as an absolute timestamp and check it on the server at click time. Never trust an expiry encoded in the link itself, because the holder of the link controls the link.
Step 6: single-use, and why “atomically” is the whole ballgame
A magic link must work exactly once. Click it, you are in; the link is now spent. Obvious. The trap is how you enforce it, and this is the bug I have watched ship more than any other in this space.
The tempting version reads like plain English:
// BROKEN: check-then-set. Do not do this.
const row = await db.query(
`SELECT user_id FROM login_tokens
WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > now()`,
[hashToken(token)]
);
if (!row) return unauthorized();
await db.query(
`UPDATE login_tokens SET consumed_at = now() WHERE token_hash = $1`,
[hashToken(token)]
);
return createSession(row.user_id);
Read it and it looks airtight. Check it is unconsumed, then consume it. The problem is the gap between the two statements, and the gap is where two requests can both slip through.
Two requests? For one link the user clicks once? Yes, and it happens constantly. Corporate email security (Outlook Safe Links, Mimecast, Proofpoint) prefetches every URL in an incoming message to scan it for malware. The scanner clicks your link before the human ever sees it. Some mobile mail apps prefetch too. So the real world routinely fires two requests at your callback within milliseconds: the scanner and the human, or a double tap on a flaky connection.
Now run both through the broken code. Request A does its SELECT, sees consumed_at IS NULL, and pauses (a query round-trip is not instant). Request B does its SELECT in that pause and also sees NULL, because A has not written yet. Both pass the check. Both run the UPDATE. Both call createSession. One link, two sessions.
The fix is not a lock you take and release, and it is not a transaction wrapped around the broken code (though that helps). The fix is to make the check and the set the same statement, and let the database do what it is good at:
UPDATE login_tokens
SET consumed_at = now()
WHERE token_hash = $1
AND consumed_at IS NULL
AND expires_at > now()
RETURNING user_id;
One statement. The WHERE consumed_at IS NULL is evaluated and the row is write-locked as a single atomic act. When two of these race for the same row, the database serializes them: the first grabs the row lock, flips consumed_at, and returns the row. The second waits for the lock, then re-evaluates its WHERE against the now-consumed row, matches nothing, and returns zero rows. (This is row-level locking doing its job; the deeper mechanics live in transactions and isolation.)
In your handler, the rule becomes dead simple: you got a row back, or you did not.
async function consumeToken(db, token) {
const { rows } = await db.query(
`UPDATE login_tokens
SET consumed_at = now()
WHERE token_hash = $1
AND consumed_at IS NULL
AND expires_at > now()
RETURNING user_id`,
[hashToken(token)]
);
return rows[0]?.user_id ?? null; // exactly one winner, or null
}
If rows.length === 1, you won; mint the session. If it is 0, the token was already spent, expired, or never existed; show the same “this link is no longer valid” page. No SELECT first. No window. The check is the set.
See it happen. Below is a self-contained model of that callback. Flip between the naive path (a read, a gap, then a write) and the atomic path (check and set with no gap), then fire two near-simultaneous clicks and watch the session counter.
Same two clicks, same latency. The only difference is whether a gap sits between the check and the set.
Step 5 and 7: the token leaks out of the URL, so scrub it
Here is a quieter problem. Your token is in the URL, and URLs are chatty. A URL ends up in:
- your server access logs, in plaintext, forever
- the browser’s history, and its address-bar autocomplete
- the
Refererheader, if the callback page loads anything cross-origin - proxy logs, CDN logs, and anywhere else a request line gets recorded
Any of those turns a “single-use, 15-minute” token into a token some log aggregator kept for ninety days. Two habits fix most of this.
First, redirect away from the token immediately after you consume it. The callback handler consumes the token, sets the session cookie, and responds with a redirect to a clean URL. The token-bearing URL never becomes the page the user sits on, so it does not linger in the address bar or leak via Referer from the app’s own page.
app.get("/callback", async (req, res) => {
const token = req.query.token;
const userId = token ? await consumeToken(db, token) : null;
if (!userId) return res.redirect("/login?error=expired");
await startSession(res, userId); // Set-Cookie: httpOnly, Secure, SameSite
return res.redirect(303, "/app"); // clean URL; the token is gone from the bar
});
The session itself is an ordinary cookie session; get the flags right in cookies done right and pick your session model in sessions vs JWT.
Second, and this is the one that saves you from the mail scanners: do not consume the token on the bare GET. Land the click on a lightweight page that says “Sign in?” with a button, and consume the token only when that button POSTs. A malware scanner issues a GET or a HEAD; it does not fill in and submit forms. So the GET renders a harmless confirmation page, the token stays unconsumed, and the human’s click on the button is the one that actually spends it.
Do not tell strangers who has an account
When someone submits an email to your login form, your response must look identical whether or not that email has an account. Same status code, same page, same wording, roughly the same timing: “If that address has an account, we have sent a link.”
Get this wrong and you have built an account-enumeration oracle. An attacker feeds a list of emails to your endpoint, watches which ones return “sent” versus “no such user,” and walks away with a verified list of who banks with you, who uses your dating app, who has an account on the thing they would rather nobody knew they used. That list has value on its own, before any password is involved.
app.post("/login", async (req, res) => {
const email = normalize(req.body.email);
const user = await db.userByEmail(email);
if (user) {
await sendMagicLink(db, email, mailer); // only really sends if the user exists
}
// identical response either way; never branch the output on `user`
return res.status(200).render("check-your-email", { email });
});
The same discipline runs through your whole error surface: leak nothing in the difference between “wrong” and “does not exist.” There is a fuller treatment in status codes and an error contract. The magic-link-specific twist is timing. If the “user exists” path sends an email and the “no user” path returns instantly, a stopwatch tells the attacker what your words would not. Doing the DB lookup on both paths, and keeping the response quick and uniform, closes the obvious gap.
Rate-limit, or you have built an email cannon
Look again at that login handler. It takes an email address and sends mail to it. If you do not throttle it, you have handed the internet a button that sends email to any address, on demand, as fast as they can call it.
Point that at a victim’s inbox and it is harassment. Point it at a thousand addresses and your sending domain’s reputation is scorched by lunchtime, which means your real login emails start landing in spam for everyone. This is not hypothetical; unthrottled “send me a link” endpoints get abused within days of going live.
Limit per email address and per client IP, both. Something like five requests per address per hour, with a short cooldown between consecutive sends, kills the abuse while barely touching a real user who mistyped once. The general toolkit, token buckets, sliding windows, where to store the counters, is in rate limiting. Just make sure the limiter sits in front of the send, not after it.
The downside nobody puts on the slide
Here is the honest cost of going passwordless with email, the one that does not show up until you are live: email deliverability is now an availability dependency.
With a password, login is a request to your own server. It works when your server works. With a magic link, every single login depends on a message getting from your sending infrastructure, through a chain of spam filters and greylisters and reputation scorers you do not control, into a folder the user actually checks, fast enough that they have not given up. When that chain hiccups, your users cannot log in, and to them it looks exactly like your site being down.
You inherit a whole discipline you did not have before. SPF, DKIM, and DMARC records are table stakes now; Gmail, Yahoo, and Microsoft will quietly junk unauthenticated mail, and for bulk senders they outright require authentication. Getting those right gets you to “not obviously spam,” which is neutral, not good. The rest is sending reputation, which is earned slowly and lost fast. And even when everything is configured perfectly, you are adding seconds to minutes of latency to your login, plus a context switch out to a mail app, which is real friction that measurably costs you sign-ins.
OTP codes: the same idea, different delivery
A one-time passcode is the magic link’s cousin. Instead of a clickable link carrying a long token, you email (or text) a short code, usually six digits, and the user types it back into the page they are already on.
The plumbing is nearly identical: generate the code, store a record tied to the user or the pending login, set a short expiry, consume it atomically on submit. Two things change, and they pull in opposite directions.
The nice change: the code stays on the tab the user started on. No new-tab redirect dance, no scanner prefetch, no token in a URL to leak. It also survives the awful in-app browsers inside some mobile apps, where tapping a link opens a webview that has none of your cookies. If you have ever gotten “the link logs me out again,” a code sidesteps the whole mess.
The dangerous change: six digits is about twenty bits of entropy. A million possibilities. That is guessable in a way a 256-bit token never is, so the code’s security cannot come from the code. It has to come from strict limits.
- Cap the attempts. Lock the code after a handful of wrong guesses (NIST’s guidance lands around 100 lifetime attempts as the ceiling; most apps use far fewer). Without this, an attacker just tries all million codes.
- Keep the TTL tiny. Five minutes, often less. A short life shrinks the number of guesses that fit in the window.
- Bind the code to the request. The code should only be accepted on the same session or device that asked for it, so an attacker cannot pump guesses at your verify endpoint independent of a real login.
- Rate-limit the verify endpoint hard, per code and per IP, on top of the attempt cap.
Because the entropy is low, hashing the stored code at rest does less for you than it does for a link token; a thief with your database can brute-force a six-digit hash instantly. The defense here is not the hash, it is the short life and the attempt cap. Do both anyway, but know which one is actually holding the line.
// six digits, uniform. Then: store, short TTL, atomic consume, attempt cap.
import { randomInt } from "node:crypto";
const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
Note randomInt from crypto, not Math.random(). Same rule as before.
Passkeys: the actual endgame
Magic links and OTP codes both make the same trade: they replace the password with the inbox. That is often a good trade, but the inbox is still a shared bearer channel that can be phished, forwarded, or intercepted. In 2026 there is a better answer for the login itself, and it has finally gone from “promising” to “normal”: passkeys.
A passkey is a public-key credential, built on the WebAuthn browser API. When you register, the user’s device generates a key pair. The private key never leaves the device; it lives in secure hardware (a Secure Enclave, a TPM, a security key) and is released only after a local gesture, a fingerprint, a face, a device PIN. Your server stores only the public key. To log in later, your server sends a random challenge, the device signs it with the private key, and your server verifies the signature against the public key it stored. There is no shared secret in the database, so there is nothing to steal that logs anyone in.
What makes passkeys genuinely different from every password-shaped scheme is that they are phishing-resistant by construction, not by user vigilance. The magic is origin binding.
Walk the phishing case, because it is the whole point. A user lands on evil.example, a pixel-perfect clone of your login. The attacker relays your real challenge to the victim’s browser. The browser asks the device to sign, and here is the trick: the browser writes the current page’s origin into the signed data, and the user cannot override it. So the device signs an assertion that says origin = evil.example. The attacker replays it to your real server, which is expecting origin = login.example, sees the mismatch, and rejects it. The phish produces a signature for the wrong site, which is worthless. There is no code the user could have typed, no link they could have clicked, that hands the attacker a valid credential. That is a category of attack that magic links and passwords simply cannot close, because a human can always be tricked into pasting a code or clicking a link on the wrong domain.
The reason 2026 is the year this matters, and not 2020, is not the protocol. WebAuthn was stable years ago. What changed is sync. Passkeys now ride your platform keychain, iCloud Keychain across Apple devices, Google Password Manager across Android and Chrome, Windows Hello, plus third-party managers like 1Password, Bitwarden, and Proton Pass. Registering a passkey on your phone means it is already on your laptop. That quietly killed the objection that sank passkeys’ ancestors: “what happens when I lose the device?” For most people now, the answer is “nothing, it synced.”
A concise sketch of both halves, using the browser API directly:
// Registration: create a key pair bound to this origin
const credential = await navigator.credentials.create({
publicKey: {
challenge, // random bytes from your server
rp: { name: "Example", id: "login.example" },
user: { id: userId, name: email, displayName: name },
pubKeyCredParams: [{ type: "public-key", alg: -7 }], // ES256
authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" },
},
});
// send credential.response (public key + attestation) to the server to store
// Authentication: sign a fresh challenge
const assertion = await navigator.credentials.get({
publicKey: { challenge, rpId: "login.example", userVerification: "preferred" },
});
// send assertion to the server; verify the signature against the stored public key
In real code you would not hand-roll the server-side verification; you lean on a maintained library (SimpleWebAuthn on the Node side is the common pick) to parse and check the attestation and assertion, because there are a dozen fiddly checks and you want the audited version. Conditional UI, the passkey autofill that shows saved passkeys right in the username field, is now shipping across the major browsers and is worth turning on; deployments that use it see multiples more passkey adoption than ones that bury it behind a separate button.
So the three are not really rivals. A common, sane shape in 2026: offer passkeys as the primary, phishing-resistant login and nudge people to enroll one; use a magic link or an email code as the on-ramp for brand-new users and as the recovery path when a passkey is unavailable. The passwordless email flow you built in the first two-thirds of this article is not obsolete. It is the safety net under the passkey.
Where this fits in a real app
Concretely, picture a small paid content library: someone buys a guide, then comes back three weeks later to read it. Forcing them to invent and remember a password for a thing they open twice a year is friction with zero payoff, and a password they will reset every time anyway. Email them a link, drop them straight into their library, done. Later, once they are a regular, offer to set up a passkey so the next visit is a fingerprint and not an inbox round-trip. That layering, link to get in, passkey to stay fast, is the passwordless story in one sentence.
None of this replaces knowing your session and cookie mechanics cold, since every one of these flows ends by minting a session. If you have not, read sessions vs JWT and cookies done right next; they are where “you are now logged in” actually lives. And if you would rather delegate the whole identity problem, OAuth and social login hands it to a provider entirely.
Summary
- Passwordless is a real product choice, not just a security one. No stored password means nothing to leak or reuse, but it makes email deliverability an availability dependency. Best for low-frequency logins; a poor fit where a fast, offline-capable login matters.
- The token is a bearer credential. Mint 32 bytes from a CSPRNG (
crypto.randomBytes), neverMath.random(), and put it in the URL asbase64url. - Hash the token at rest with a fast hash (SHA-256), not argon2. High entropy is the defense, so a slow hash buys nothing; the hash just makes a leaked table row worthless instead of a live login.
- Enforce single-use atomically. A single conditional
UPDATE ... WHERE consumed_at IS NULL RETURNINGlets the row lock pick exactly one winner. Check-then-set has a gap, and mail scanners that prefetch links will drive two requests straight through it. - Short TTL (about 15 minutes), checked server-side against an absolute
expires_at. - Scrub the token from the URL. Redirect to a clean URL after consuming, and consume on a POST from a confirmation page so link-prefetchers and the
Refererheader cannot burn or leak it. - Never reveal whether an account exists. Identical response and timing for known and unknown addresses, or you have built an enumeration oracle.
- Rate-limit per address and per IP in front of the send, or your login endpoint is an email-bombing tool and a reputation grenade.
- OTP codes trade the URL-leak problem for a low-entropy one: defend them with tiny TTLs, strict attempt caps, and request binding, not with hashing.
- Passkeys are the 2026 endgame: origin-bound public-key credentials that are phishing-resistant by construction, synced across platform keychains. Pair them with a magic link or code as the on-ramp and recovery path, and harden that recovery path like a front door.