Magic Link vs OTP: Passwordless Email Auth Trade-offs
Magic links and one-time codes both kill the password, but they fail in different places. Pick the wrong one and you lose users on a phone-to-laptop handoff or burn deliverability on a clickable token.
By SESMetric Editorial
Passwordless email auth comes in two flavors, and they fail in different places. A magic link feels effortless on a laptop and falls apart the moment a user opens the email on their phone. A one-time code is dull but survives the handoff. Pick the wrong one for your audience and you either bleed conversions on the login screen or end up with a security model that is one inbox compromise away from a takeover.
This article walks through the trade-offs an engineer actually has to make: device-switching UX, token lifetime, deliverability, session binding, and the production code that backs each flow.
Magic link vs OTP: how each one actually works
A magic link is a signed URL you mail to the user. When they click it, your callback verifies the signature, marks the token as consumed, and starts a session in the browser that opened the link.
A one-time password (OTP) is a short numeric code, almost always six digits. You mail the code, the user reads it, and they type it back into the screen where they started the flow. Your server compares the submitted value against a hashed, time-boxed record and starts a session in the original tab.
The state machines look similar — issue, deliver, verify, consume — but the verification surface is different. A magic link binds the new session to whichever browser opened the link. An OTP binds the session to whichever tab the user typed it into. That single distinction is responsible for most of the UX and security differences below.
UX trade-offs: the device-switching trap
Users do not read mail where they log in. They start signing in on a laptop, then check their inbox on a phone because the mail app is already open. With a magic link, tapping the link on the phone opens a new browser session on the phone, not the laptop tab they started in. The laptop sits there spinning while the user shrugs and gives up. Mobile clients make this worse: iOS Mail and the Gmail app sandbox links into an in-app browser that does not share cookies with Safari or Chrome, so even a phone-to-phone flow can land in the wrong context.
OTP sidesteps this entirely. The user reads six digits on whichever device the mail landed on and types them on whichever device started the login. The handoff is the user's eyeballs, not a clickable URL. Cross-device flows that take three taps with OTP take three taps and a swear word with a magic link.
Magic links win when both events happen on the same device, in the same default browser, with no email forwarding in between. That is a real use case — a developer logging into a dashboard from their work laptop — but it is not the median consumer flow. Measure your own funnel before assuming.
Security model
Magic link tokens live in URLs, and URLs leak. They show up in browser history, in Referer headers when the post-login page loads a third-party resource, and in any email forwarding chain the user kicks off without thinking. To make the click experience tolerable, teams usually stretch the TTL to ten or fifteen minutes, sometimes an hour. That window is the blast radius if the user's inbox is ever compromised.
OTP tokens are short, but the short value is the point. Six digits give a million possibilities, which is plenty when the verify endpoint rate-limits to five attempts inside a five-minute window. The token never enters a URL bar, never gets pasted into a chat, and is harmless to leak after it has been consumed.
If the email account itself is breached, both schemes are in trouble — the attacker simply requests a fresh credential. The difference is the time-and-place trail they need to cover. A magic link click can succeed silently in the background; an OTP requires the attacker to drive both ends of the flow. Bind the issued credential to additional context — the IP, the user agent, or a pre-auth nonce dropped in the originating tab — so that even a stolen token cannot be replayed from a different network. The bind does not stop a determined attacker, but it raises the noise floor enough to trip your anomaly detection.
Deliverability impact
Filters treat clickable links as a risk signal. A magic-link mail is almost entirely one bright button to a long opaque URL, which is structurally identical to a phishing template. Outlook's Safe Links and Defender for Office routinely rewrite or prefetch those URLs, and a prefetch counts as a click. If your token is single-use, the prefetcher consumes it before the human ever sees the mail. You then ship a ?nonce= query parameter, a one-time-on-second-click extension, or you accept a flaky flow.
OTPs ship as plain text. There is no URL to rewrite, no link to prefetch, and the message reads correctly even when the recipient's client strips HTML. Spam filters score short transactional text bodies favorably. The trade-off is that magic links naturally tolerate longer subject lines and richer branding, while OTP mails should stay terse so the code is visible in the notification preview.
Either way, send from an authenticated subdomain dedicated to transactional traffic and keep DKIM, SPF, and DMARC aligned. Mixing magic-link auth mail with marketing IP reputation is how login flows quietly stop working.
OTP generation and verification
A correct OTP server is short, hashed at rest, time-boxed, and attempt-capped. The code below uses secrets for entropy, SHA-256 to avoid storing the plaintext, and a Redis hash to track attempts atomically.
import secrets, hmac, time
from hashlib import sha256
OTP_TTL_SECONDS = 300
MAX_ATTEMPTS = 5
def generate_otp() -> str:
return f"{secrets.randbelow(10**6):06d}"
def store_otp(redis, email: str, code: str) -> None:
digest = sha256(code.encode()).hexdigest()
key = f"otp:{email}"
redis.hset(key, mapping={"hash": digest, "attempts": 0, "issued": int(time.time())})
redis.expire(key, OTP_TTL_SECONDS)
def verify_otp(redis, email: str, submitted: str) -> bool:
key = f"otp:{email}"
record = redis.hgetall(key)
if not record:
return False
if int(record[b"attempts"]) >= MAX_ATTEMPTS:
redis.delete(key)
return False
redis.hincrby(key, "attempts", 1)
expected = record[b"hash"].decode()
submitted_hash = sha256(submitted.encode()).hexdigest()
if hmac.compare_digest(expected, submitted_hash):
redis.delete(key)
return True
return False
The constant-time compare via hmac.compare_digest is non-negotiable. The TTL of five minutes is long enough for a user to alt-tab to their inbox and short enough that a leaked code is useless by the time anyone could replay it.
Signed magic-link tokens
For magic links, sign a small JSON payload with HMAC-SHA-256, expire it on the client side via an exp claim, and enforce single-use on the server side via a Redis jti record. Skip JWT libraries if you do not need the ecosystem — a raw HMAC is easier to audit.
import crypto from "node:crypto";
const SECRET = process.env.MAGIC_LINK_SECRET;
const TTL_MS = 10 * 60 * 1000;
function sign(payload) {
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
const mac = crypto.createHmac("sha256", SECRET).update(body).digest("base64url");
return `${body}.${mac}`;
}
export function issueMagicLink(email, redis) {
const jti = crypto.randomBytes(16).toString("hex");
const exp = Date.now() + TTL_MS;
const token = sign({ email, jti, exp });
redis.set(`mlink:${jti}`, "1", "PX", TTL_MS);
return `https://app.example.com/auth/callback?t=${token}`;
}
export async function verifyMagicLink(token, redis) {
const [body, mac] = token.split(".");
const expected = crypto.createHmac("sha256", SECRET).update(body).digest("base64url");
if (!crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(expected))) return null;
const payload = JSON.parse(Buffer.from(body, "base64url").toString());
if (payload.exp < Date.now()) return null;
const consumed = await redis.del(`mlink:${payload.jti}`);
if (consumed !== 1) return null;
return payload.email;
}
The redis.del returning 1 is what makes the token single-use: a second click finds nothing to delete and gets rejected. If you operate behind a link-prefetching proxy, gate consumption on a follow-up POST from the landing page rather than the initial GET.
Session binding patterns
A token by itself only proves possession; it does not prove the user. Bind it to context the attacker would need to replicate. The cheapest binding is a pre-auth nonce: when the user enters their email, drop a signed cookie on the originating tab and require the callback to present that nonce alongside the magic-link token. The token alone is then useless from a different browser.
For OTP, the originating tab is already trusted because the user types the code into it; bind the verify call to a session cookie that was set when the code was issued. For cross-device OTP — the user wants to log in on a TV by typing a code from their phone — issue a continuation token at request time, show it as a QR or short string on the TV, and require both the OTP and the continuation token on verify. The complexity is real, but it cleanly separates "who has the code" from "where the session lives."
Layer in IP and user-agent checks as risk signals rather than hard gates. Mobile networks rotate IPs and corporate proxies rewrite user agents; a strict check will lock out more legitimate users than attackers.
When to pick which
For a consumer B2C product with heavy mobile traffic — anything where the user might sign up from a phone they are holding while standing in line — default to OTP. The device-switching cost is the dominant UX risk, and the security profile is at least as good as a magic link with a ten-minute TTL.
For a B2B internal tool where employees live in desktop email and rarely switch devices mid-flow, a magic link is fine and often preferred. The click feels modern, the email can carry richer branding, and the deliverability hit is smaller when you are mailing your own corporate domain.
For high-security contexts — finance, infrastructure, anything with a regulator — neither magic link vs OTP alone is sufficient. Use OTP as the primary factor because of the smaller leak surface, then layer WebAuthn or a hardware token as a step-up before sensitive actions. The email credential becomes a recovery and bootstrap channel, not the gate on production.
Whichever you pick, the email pipeline behind it has to be fast and clean. A login mail that arrives ninety seconds late is functionally a broken login. Send transactional auth mail on infrastructure tuned for transactional latency, keep it isolated from marketing reputation, and instrument the click-or-type rate so you notice deliverability drift before your users do.