Your passwordless login is protected by a password
Magic link security explained: why the random token is the easy half, and how leaked links, email scanners and pre-hijacked accounts take over accounts anyway.
- security
- vibe coding
- engineering
NIST publishes a list of reasons that email cannot be used to authenticate anybody, and the first item on it is the joke that writes this entire post. SP 800-63B revision 4, section 3.1.3.1, says “Email SHALL NOT be used for out-of-band authentication because it may be vulnerable to:” and then lists three things. Here is the first:
Access using only a password
That is the state of the art in passwordless login. You remove the password from your own application, and the account is now guarded by a password on a server you do not run, chosen by a user you cannot advise, protected by a recovery flow you have never read.
The other two reasons NIST gives are “Interception in transit or at intermediate mail servers” and “Rerouting attacks, such as those caused by Domain Name System (DNS) spoofing”. Every one of the three is about the channel. None of them is about your token being predictable, and neither is anything else in this post. The four failures below are a critical CVE, a high-severity advisory in a library that ships in a lot of AI-generated Next.js apps, a three-year-old open issue in Supabase, and a CVE in an enterprise identity server. Not one of them is a randomness bug. Every one of them ships a token you could not guess in a thousand years.
The line NIST draws, and the one magic links cross
The prohibition above has a carve-out immediately underneath it, and the carve-out is more useful than the rule:
Confirmation codes that are sent to validate email addresses or are issued as recovery codes (see Sec. 4.2.1.2) are not authentication processes and not affected by the above prohibition.
So NIST is not banning emailed links. It is drawing a line exactly where the industry has spent five years blurring it. Sending a link to prove that somebody controls an address is fine, and NIST says so in as many words. Sending a link that logs somebody in is authentication, and email is not allowed to do that.
Revision 3 of the same document made the point more obliquely, as a property rather than a prohibition: “Methods that do not prove possession of a specific device, such as voice-over-IP (VOIP) or email, SHALL NOT be used for out-of-band authentication.” Revision 4 promoted email to its own sentence and attached reasons. The direction of travel is not subtle.
Now the uncomfortable part, which most write-ups of this subject skip. If your application has a “forgot password” link, you already granted email the power to mint sessions. An attacker who owns the mailbox does not need your magic link; they need ninety seconds and your reset flow. Refusing to ship magic links while shipping password reset is not a security posture, it is a preference. Everything below applies to both, which is why the failures show up in reset flows and sign-in flows interchangeably, and why password reset poisoning is the same family of bug seen from a different angle.
What actually changes between the two is frequency and expectation. A reset link is issued rarely and treated as sensitive. A magic link is issued on every sign-in, is the default path rather than the recovery path, and is therefore built by whoever was building the login screen that afternoon.
The link that never needed an inbox
Start with the purest possible version of the bug. CVE-2026-39912 is a critical (CVSS 3.1 base score 9.1) unauthenticated account takeover in V2Board and Xboard, two widely deployed PHP subscription panels. The GitHub advisory describes it in one paragraph:
V2Board 1.6.1 through 1.7.4 and Xboard through 0.1.9 expose authentication tokens in HTTP response bodies of the loginWithMailLink endpoint when the login_with_mail_link_enable feature is active. Unauthenticated attackers can POST to the loginWithMailLink endpoint with a known email address to receive the full authentication URL in the response, then exchange the token at the token2Login endpoint to obtain a valid bearer token with complete account access including admin privileges.
The classification is CWE-201, Insertion of Sensitive Information Into Sent Data, which is a polite way of saying the server told the attacker the answer.
Here is the code, from
app/Services/Auth/MailLinkService.php
at the vulnerable commit. Read it as a checklist of things done right:
$code = Helper::guid();
$key = CacheKey::get('TEMP_TOKEN', $code);
Cache::put($key, $user->id, 300);
Cache::put(CacheKey::get('LAST_SEND_LOGIN_WITH_MAIL_LINK_TIMESTAMP', $email), time(), 60);
$redirectUrl = '/#/login?verify=' . $code . '&redirect=' . ($redirect ? $redirect : 'dashboard');
if (admin_setting('app_url')) {
$link = admin_setting('app_url') . $redirectUrl;
} else {
$link = url($redirectUrl);
}
$this->sendMailLinkEmail($user, $link);
return [true, $link];
The token comes from Helper::guid(), which draws 16 bytes from
openssl_random_pseudo_bytes on any build without the COM extension. It expires
in 300 seconds, because that is the
third argument to Cache::put. It is single use, because handleTokenLogin
calls Cache::forget($key) after a successful exchange. There is a 60 second
per-address send cooldown on the line right after it. Five-minute expiry,
single use, cryptographic randomness, rate limited. If you handed this to a
reviewer as a snippet with the last line removed, it would pass.
The last line returns the link to the caller, and the controller hands it straight to the client:
[$success, $result] = $this->mailLinkService->handleMailLink(
$params['email'],
$request->input('redirect')
);
if (!$success) {
return $this->fail($result);
}
return $this->success($result);
An unauthenticated POST with somebody’s email address returns their login link in the response body. The mailbox, which was the entire security model, is now an optional delivery method that the attacker declines to use.
The fix is one line, and its commit message is the best summary of magic link security anyone has written:
$this->sendMailLinkEmail($user, $link);
- return [true, $link];
+ return [true, true];
}
The loginWithMailLink endpoint returns the magic login link in the HTTP response body, allowing unauthenticated account takeover.
The fix returns true instead of the link. The email delivery is the authentication factor.
“The email delivery is the authentication factor.” Not the token. The token is just an identifier for a fact, and the fact is that a message reached an inbox. Anything that reveals the token outside the inbox has not weakened the authentication, it has removed it.
The same commit message adds a detail worth sitting with: “Bug inherited from V2Board commit bdb10bed (2022-06-27).” The line shipped in 2022 and was disclosed in 2026.
The password that survives the passwordless login
The second failure is subtler and much more likely to be in your codebase, because it lives in a library rather than in code somebody wrote by hand.
GHSA-qq9h-g4jm-xgf3
is a high-severity advisory (CVSS 3.1 base score 8.3) against better-auth,
covering every version from 1.1.3 up to but not including 1.6.22, plus the 1.7.0
betas up to but not including 1.7.0-beta.10. It was reported by the Vercel
security team. The summary:
An attacker can keep password access to a victim’s account after the victim starts using it. The attack runs in three steps. First, with open registration, the attacker signs up using the victim’s email and a password the attacker picks. The account stays unverified, so the attacker cannot use it yet. Later the real owner signs in with a magic link or an email OTP. That step marks the account verified, and the attacker’s password now works on it.
The mechanism, in the advisory’s own words:
When an account already exists for an address, magic-link verification and email-OTP sign-in both sign in to that account. They mark it verified and issue a session. Before the fix, neither one removed a password set while the account was still unverified. Neither one revoked existing sessions. So a password set before anyone proved control of the mailbox kept working after the owner proved control.
And then the sentence that should be printed on a card and taped to the monitor of anyone who has ever written an email verification flow:
Requiring email verification does not stop it. The verification step is the exact moment that turns the planted account into a usable one.
This is the pre-hijacking class, named and measured in Pre-hijacked accounts: An Empirical Study of Security Failures in User Account Creation on the Web by Avinash Sudhodanan and Andrew Paverd at USENIX Security 22. Their abstract gives the only real number in this post that comes from a survey rather than a single incident:
To ascertain the prevalence of such vulnerabilities in the wild, we analyzed 75 popular services and found that at least 35 of these were vulnerable to one or more account pre-hijacking attacks.
Note the shape of the attacker’s advantage. They act before the victim exists as a user, which means no monitoring you have ever built is watching. The advisory says it plainly: the takeover “only completes when the victim signs in through the passwordless flow.”
The fix is the part worth copying, because it states a rule rather than patching a symptom:
The fix treats current proof of control over the address as authoritative. When either flow finds an account whose email was never confirmed, it now removes the password and revokes existing sessions first. Only then does it mark the account verified and issue the new session. The owner signs in as before. Only access created before the proof is removed.
In code, that is a new helper called at exactly one moment:
if (!user.emailVerified) {
await revokeUnprovenAccountAccess(ctx, user.id);
user = await ctx.context.internalAdapter.updateUser(user.id, {
emailVerified: true,
});
and the helper itself, whose doc comment is the clearest statement of the principle I have found in a real codebase:
/**
* Strip every credential and session a pre-existing account accrued before
* control of its email was proven.
*
* An `emailVerified: false` row carries no proof that the password on it belongs
* to the mailbox owner. When an email-primary proof (magic link, email OTP)
* resolves to such a row, deleting the `credential` account and revoking standing
* sessions makes the verified owner inherit no password or session that predates
* the proof. Call this before flipping `emailVerified` and minting the owner's
* session; it no-ops if a concurrent flow has already verified the account.
*
* @param userId - The pre-existing, not-yet-verified user being promoted.
*/
The same file ships an honest FIXME admitting the re-read and the strip are not
atomic across the database and the session store, and noting that the worst case
is a just-confirmed password being cleared, which is “recoverable by reset, never
a security loss”. A comment that names the residual race and argues why it fails
safe is worth more than a comment claiming the problem is solved.
The rule generalizes past this library. Proof of control over an address invalidates everything that was attached to the account without that proof. Passwords, sessions, API keys, linked identities, all of it. If your magic link signs somebody in without revoking what was there before, you are not authenticating them, you are admitting them to a room that may already be occupied. It is session fixation logic applied to account creation instead of to a session id: the identifier that existed before the proof does not get to survive the proof.
The scanner that opens your link before your user does
Now the failure that is not an attack at all, and that has broken more magic link deployments than every attacker combined.
Corporate mail security opens links. Not renders, not previews. Fetches. From supabase/auth issue #1214, opened in August 2023 and still open at the time of writing:
We have a customer using the Barracuda SafeLinks platform, and apparently Office 365 offers something similar, which scans all links before opening them via a wrapper link it puts on all incoming email. This invalidates Magic Links as the link scanning platform triggers expiration when the user opens the link.
Issue #713 describes the same thing from the application side:
As part of the system’s security, when an email is to be delivered to a user’s inbox the system will open the email, scan through the email, and open all of the links that are contained within the email. If the system deems that the email is safe, it then delivers it into the user’s inbox.
So the token is spent before the message is in the mailbox. Single use worked perfectly. It just worked for the wrong client.
Supabase’s own documentation names this as a limitation: “Certain email providers may have spam detection or other security features that prefetch URL links from incoming emails”, and their troubleshooting page calls email prefetching “The most common reason for OTP tokens appearing expired or invalid before a user can even use them”.
The reflex when this lands in your support inbox is to relax the token, and the same issue thread walks straight into it. The reporter of #713 proposes “a possible alternative that would work for these systems would be to have an access_token that isn’t single-use, but rather has an expiration timestamp (10 minutes, one hour, one day, etc.)”. That trade gives away the one property that bounds the damage of a leaked link, in exchange for a symptom. Hold onto single use and fix the thing that is consuming the token.
The two fixes that do not work
The first instinct is to identify the robot. Issue #368 proposed exactly that, and the proposal is struck through in the issue body itself. The pull request that implemented it, #369, titled “fix: block bots from consuming verification tokens”, was closed without merging. The reason is one comment:
ok this approach doesn’t work for the Mac Mail client because the user agent string comes through as:
Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Safari/605.1.15
A client that wants to look like a browser looks like a browser. There is no version of user-agent sniffing that survives contact with a mail client that has a WebKit in it.
The second instinct is the one in Supabase’s troubleshooting guide, and it is
worse because it sounds technical. The page suggests that mail providers “offer
methods to prevent or mitigate automated prefetching, such as specific HTML
attributes (e.g., rel="noreferrer noopener")”. They do not, and that attribute
cannot do this. rel="noopener" severs window.opener for a document a browser
opens, and rel="noreferrer" suppresses the Referer header on a navigation a
browser performs. Both are instructions to a browser about a user’s click. A
security crawler parses the HTML and issues its own GET; it is under no
obligation to read a rel attribute, and there is no reason it would.
The fix that half works
NextAuth documented a real workaround for one real scanner. From Allow Email Signups Behind Corporate Link Checker:
In the specific case of Outlook and their “SafeLink” feature, they send a HEAD request to each link in the Email. This request will trigger the NextAuth.js catch-all API Route with the users invitation token, in effect using it up.
The workaround is to answer HEAD before any logic runs:
export default async function auth(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "HEAD") {
return res.status(200).end()
}
...
}
This is correct and you should do it, but understand its scope. It only helps against scanners that send HEAD. Supabase is not vulnerable to that particular probe at all, because its router registers only two methods on the verification endpoint:
r.With(api.limitHandler(api.limiterOpts.Verify)).Route("/verify", func(r *router) {
r.Get("/", api.Verify)
r.Post("/", api.Verify)
})
and the handler’s fallback arm is
panic("Only GET and POST methods allowed"), under the comment
// this should have been handled by Chi. A HEAD probe never reaches the token
logic. Supabase still gets broken by scanners, constantly, because the ones in
issues #713 and #1214 are the ones that actually open the link with a GET.
Answering HEAD is a patch for one scanner’s politeness, not a fix for the class.
The fix that works
A GET must not change state. That is the whole answer, and it predates every product in this post.
Supabase’s documented Option 2 is the correct shape:
<a href="{{ .SiteURL }}/confirm-signup?confirmation_url={{ .ConfirmationURL }}">
Confirm email address
</a>
The emailed link now goes to a page on your site that carries the real confirmation URL as a parameter and renders a button. A crawler fetches that page, learns that it is a page, and stops. A human clicks the button, which issues the request that actually spends the token. The token survives every robot in the chain because no robot performs the interaction that consumes it.
Their Option 1 is the same idea taken further: put a code in the mail rather than a link, and make the user type it into a form your application already has open. That is strictly better again, because it re-binds the flow to the browser that started it. A code typed into the tab that requested it cannot be used by someone who forwarded the email, which is exactly the binding property that the OAuth state parameter exists to provide and that a bare link has never had.
A day is a long time to hold a login
Expiry is the one property everybody remembers to implement and almost nobody
reads the default for. Here is Supabase’s, from
internal/conf/configuration.go:
if config.Mailer.OtpExp == 0 {
config.Mailer.OtpExp = 86400 // 1 day
}
One day, for every email link the service issues: sign-up confirmation, magic
link, recovery, invite, email change. Hosted projects get a shorter value from
the dashboard, but a self-hosted deployment that never set MAILER_OTP_EXP is
handing out login links that stay live for twenty-four hours. The SMS default is
set seventeen lines further down the same function, and it is 60 seconds.
The comparison that matters is not link versus link, it is link versus what the link replaced. A password is a long-lived secret protected by the fact that it is never transmitted in a URL, never sits in a mailbox, and is not written down in the recipient’s history. A magic link is a bearer credential sitting in plaintext in an inbox for a day, in a message that gets forwarded, backed up, synced to three devices, and indexed by a desktop search tool. Anyone who reads that mailbox at any point in the next twenty-four hours logs in as that user, and nothing about the token’s entropy is relevant to any of it. That is the same property that makes a stolen bearer token so valuable in session hijacking, except this one was mailed.
Two more Supabase behaviors are worth knowing before you ship on top of them.
Single use is real, and is implemented by clearing the column: Recover sets
u.RecoveryToken = "" and then calls ClearAllOneTimeTokensForUser. And the
sign-in call creates accounts by default. From
the passwordless guide:
“If the user hasn’t signed up yet, they are automatically signed up by default.
To prevent this, set the shouldCreateUser option to false.”
That default is defensible for a consumer product, and is worth a second thought after reading the previous section: an unauthenticated POST that creates user rows is the raw material a pre-hijacking attacker works with. If your application also allows password sign-up at the same addresses, decide deliberately which flow is allowed to bring an account into existence.
The second door nobody re-checked
The last failure is the shortest to describe and the easiest to reproduce in your own codebase, because it is not really about magic links at all.
CVE-2025-10908, against the WSO2 identity products, in NVD’s words:
Due to a lack of user account state validation during authentication, locked user accounts can be successfully authenticated using Magic Link or Pass Key methods. This bypasses the intended security control that should prevent access to accounts that have been locked.
NVD records CWE-863, Incorrect Authorization, and a secondary CVSS 3.1 base score of 7.3 (high). The account lockout worked. It worked on the password path, where it was written and where it was tested. The magic link path was a second entrance into the same building, added later, and it did not ask the question the front door asks.
Every alternate sign-in path is a second implementation of your entire authentication policy: lockout, ban, MFA requirement, tenant membership, suspended subscription, forced password rotation, email verification. A magic link that skips one of them is a privilege escalation waiting for someone who reads your changelog. It is also the most common way an MFA requirement gets bypassed without anyone touching the MFA code: the second door was built without it.
The Xboard code from the first section actually gets this right, which is worth
acknowledging. Its handleTokenLogin re-checks if (!$user || $user->banned)
before returning the user id. It returned the link in the response body, but it
did not forget the ban.
The practical form of the rule: the magic link handler must not build a session
itself. It should resolve the token to a user and then call the same
establishSession(user) that the password path calls, so that any check added to
that function is added to every door at once.
Writing the redemption yourself, if you own the callback
Everything above is about somebody else’s library. If you are writing the redemption yourself, the properties are short enough to list and the code is short enough to read.
Our own control plane does not ship magic links, but it ships the identical primitive for password reset, and the interesting decisions are in the same places. Start with the token:
/// Mint a token for an emailed link: 32 bytes (256 bits) from the OS CSPRNG,
/// hex-encoded. Returns the raw token (goes in the link) and its hex SHA-256
/// (the only thing stored). High entropy, so a fast hash is safe.
fn generate_link_token() -> (String, String) {
use rand::RngCore;
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
let token = hex::encode(bytes);
let hash = hash_token(&token);
(token, hash)
}
Two decisions in six lines. The plaintext exists only in the outgoing mail; the database stores a SHA-256 of it, so a database read does not yield usable links. And the hash is deliberately fast rather than memory-hard, because the input is 256 bits of CSPRNG output and there is nothing to brute force. Argon2 on a random 256-bit value buys nothing and costs a slot in the hashing queue that a real sign-in is waiting for. That reasoning inverts completely for user-chosen secrets, which is the entire subject of password hashing failures.
Then the redemption, which is the only part that has to be exactly right:
UPDATE password_reset_tokens SET consumed_at = now()
WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > now()
RETURNING user_id
One statement. It returns a user id if and only if the row was unused and unexpired at the instant it was claimed, and it marks the row used in the same statement. Zero rows returned means unknown, expired, or already spent, and the caller treats all three as one dead link, which is also why the endpoint answers with a single error code for all of them.
The property this buys is worth naming, because a read followed by a write does
not have it. Two requests carrying the same token, arriving in the same
millisecond, hit one row: Postgres serializes them, the first UPDATE matches,
the second sees a non-null consumed_at and matches nothing. Single use becomes
a database guarantee rather than an application intention. The check-then-act
version of this code passes every test you would write for it and loses a race in
production against a mail client that fires two requests.
The rest of the design is decisions rather than code:
- The ledger is a separate table from email verification tokens, not one
table with a
purposecolumn. Two token kinds that authorize different things in one ledger puts token confusion, a verification token accepted at the reset endpoint, a single missing predicate away. Two tables make it impossible by construction. - Completing a reset revokes every session and returns no session token.
Minting one would contradict the revocation. This is the same conclusion
better-authreached from the other direction. - A rate limit keyed on the client cannot see a flooded inbox. The per-IP
tier does not notice one victim being mailed from a thousand addresses, so
there is a separate per-account cooldown, enforced inside the
INSERTrather than as a read followed by a write, for the same reason the redemption is one statement. - The lookup is an indexed equality on the hash. That is what keeps the fast hash defensible: one probe, no scan, no timing surface worth the name.
Add to that list the thing our reset flow gets for free by being a reset flow, and a magic link does not: a link that lands somewhere is not a link that logs you in. It lands on a form. If you are building sign-in, put a button between the GET and the session, per the previous section, and you have the same property.
What a scan can see, and what it cannot
Almost none of this is visible from outside, and saying so precisely is more useful than implying otherwise.
What a passive scan does catch is the leak surface around the link. Our free
scanner records no-referrer-policy as a LOW finding, in these words:
The full URL of the page a visitor was on is sent to every site they click through to, which leaks anything you keep in a path or a query string, such as reset tokens and internal identifiers.
The runtime engine in a full scan records the same id at the same severity in its own wording, so a finding means the same thing on either surface. That check, plus the cookie attributes on whatever session the link eventually mints, is the honest extent of it. You can run the header half on any URL with the security headers checker and the cookie half with the cookie security checker.
What no external scan can see is every failure in this post. Whether the GET consumes the token or a button does. Whether the response body carries the link. Whether an unverified password survives the sign-in. Whether the magic link handler re-checks the ban that the password handler checks. Those live in code and in the database, which is why a release decision that only looked at the running app would have passed all four of these applications, and why the release gate reads five layers rather than one. You can see what the outside half looks like on your own URL with the free scan, and it will not tell you any of the four.
The mailbox is the account
The recurring mistake is treating the token as the secret. It is not. The token is a claim check for a delivery, and the delivery is the authentication. Every failure above follows from getting that backwards.
Return the link in a response body and you delivered it to the attacker. Let a scanner GET it and you delivered it to Barracuda. Let it live a day in an inbox and you delivered it to everyone who reads that inbox this week. Sign somebody in without revoking what was already attached to the account and you delivered the account to whoever got there first.
Which puts the design question in one line, and it is not a question about entropy. Ask what your application believes when the link is used. If the answer is “somebody controls this mailbox”, that is exactly what NIST says an emailed code is for, and you can build on it. If the answer is “this is the account owner and they are allowed in”, you have promoted a delivery receipt to a credential, and the four bugs above are the specific ways that promotion goes wrong.