The link was already used
A one-time sign-in link is a bearer token in a URL, travelling through machines whose job is to open URLs. Here is the exact mechanism, the traces, and why the numeric fallback dies with it.
Passwordless email sign-in has one shape. A user submits an address; you mint a single-use token; you store a hash of it against that address with an expiry; you put the raw token in a URL and email it. The user opens the URL, your endpoint hashes the presented token, finds the row, marks it consumed, and issues a session. The single-use property is the entire security argument and it is a sound one.
The failure is not in the argument. It is that a bearer token in a URL is redeemed by whoever dereferences the URL first, and between your SMTP handoff and a human thumb sits a queue of software built specifically to dereference URLs: mail-gateway malware scanners, endpoint antivirus, link-preview renderers, and URL-rewriting products that fetch every link in a message before delivery. None of them are attacking you. They are performing their function.
The trace
The demonstration needs no browser and no click. This is a plain HTTP client issuing one GET against a live verify endpoint:
$ curl -sS -o /dev/null -D - --max-redirs 0 \
'https://<auth-host>/auth/v1/verify?token=<raw>&type=signup&redirect_to=https://<app>'
HTTP/2 303
location: https://<app>/#access_token=eyJhbGciOiJFUzI1NiIsImtpZCI6…
&refresh_token=…&expires_in=3600&token_type=bearerThat 303 is a complete, valid session, issued to a process that has no idea what to do with it. The row is now consumed. When the human taps the same link some seconds later, the same endpoint finds a spent row and redirects with a different payload:
location: https://<app>/#error=access_denied
&error_code=otp_expired
&error_description=Email+link+is+invalid+or+has+expiredWhy nobody sees the error
Look at where those parameters live. Both the success payload and the failure payload arrive in the URL fragment, after the hash. That is deliberate — it is the implicit flow, and the point of the fragment is that user agents do not transmit it. The access token never touches the redirect target's server, which is exactly what you want for a token.
The consequence is that the failure is unobservable everywhere you would normally look. Your app server sees a plain GET for the landing page and returns 200. Access logs show 200. Uptime checks are green. There is no 4xx anywhere in the stack, because as far as HTTP is concerned nothing went wrong. The only software in the world positioned to notice is client-side JavaScript on the redirect target, and only if it is written to parse the fragment.
The error exists in exactly one place: a string in the address bar of a page that returned 200.
The default redirect target is whatever the auth provider has configured as its site URL, which for most projects is the marketing root — a page with no reason to parse an auth fragment. So the standard configuration routes an authentication failure to the one page guaranteed to ignore it. The user taps a link in their email and lands on the homepage. Nothing is red. Nothing is logged. There is no error to report.
The fallback shares the row
Most implementations put a numeric code in the same email — type this instead of tapping. It reads like redundancy: two independent routes, so if one is blocked the other carries you. It is not redundancy. Ask the admin API for a link and look at what comes back:
POST /auth/v1/admin/generate_link { type: 'signup', email }
{
action_link: 'https://<auth-host>/auth/v1/verify?token=…', // the raw token
hashed_token: '9fb0144601e6f636…', // the stored key
email_otp: '48210937' // 8 digits
}Three surfaces, one row. The link carries the raw token, the code is a second encoding of the same secret, and the hash is what the database is keyed by. Consume through any surface and the row is marked used. So the escape hatch is welded to the thing it was meant to escape, and this is measurable in about four lines:
// control: nothing has touched the link
POST /auth/v1/verify { type:'signup', email, token: email_otp }
→ 200 { access_token: 'eyJ…' } // signed in
// same setup, but fetch the action_link once first
await fetch(action_link) // 303, row consumed
POST /auth/v1/verify { type:'signup', email, token: email_otp }
→ 403 { error_description: 'Token has expired or is invalid' }A person whose link was eaten is told to type the code, types it correctly, and is told the code is wrong. Nothing they can do from their side will work, and the error text names expiry — which is false, and sends them looking for a fresh email that will meet the same scanner.
Why the standard mitigation fails
The first advice you find is to distinguish the prefetch from the human at the protocol layer: ignore HEAD, consume only on GET. This assumes scanners preflight. Some do. The ones that matter do not — Microsoft's link protection issues a GET, and it is byte-identical in method and semantics to a person tapping. There is no header, no method, and no user-agent you can rely on, because any request a scanner can construct is a request a browser can construct.
Two further wrinkles make request inspection worse than useless here. URL-rewriting products replace your link with one on their own domain carrying yours encoded inside, so the URL the user's client dereferences is not the URL you sent. And the fetch may originate from the recipient's own network, so IP and geo heuristics point at the user.
PKCE is not the answer either, for consumer email
The obvious upgrade is to stop using the implicit flow. Under PKCE the link carries a code, and redeeming it requires a verifier held in the browser that began the flow — so a scanner fetching the URL gets a code it cannot exchange, and the burn problem disappears.
It also breaks the most ordinary thing a person does. Request the link on a laptop, then open the email on a phone, and the verifier is on the wrong device. PKCE is correct for a flow that begins and ends in one browser context. Email sign-in routinely does not, and the failure it introduces is not a rare corporate-scanner case but a normal user with two devices. For consumer products the cure is worse.
Move the token out of the URL's effect
What is left is to make the URL inert. The email links to a page you own, carrying the hashed token — not the raw one — as a parameter. Rendering the page performs no verification. A button on it does, from client-side JavaScript. Scanners dereference URLs; they do not lay out a document and dispatch a click on what they find.
// before: the URL *is* the side effect
<a href="{{ .ConfirmationURL }}">Sign in</a>
→ any GET spends the row
// after: the URL is a document; the press is the side effect
<a href="{{ .SiteURL }}/auth/confirm
?token_hash={{ .TokenHash }}&type=signup">Sign in</a>
// on the page, only in the click handler:
await supabase.auth.verifyOtp({ token_hash, type })Note what is now in the URL. The template emits the hashed token rather than the raw one, so the string sitting in every mail server's logs is the database key, not the secret it protects. The raw token no longer leaves your system at all.
One trap in that template
The ampersand is written & because the template output is HTML and an attribute value is parsed as HTML. Every conforming client decodes it to & before navigating. A client that does not — one that scrapes the attribute as a literal string and hands it to a browser — produces a query whose second parameter is named amp;type.
new URLSearchParams('token_hash=abc&type=signup').get('type')
→ null // it parsed 'amp;type'
// so a signup token gets verified as the wrong type:
verifyOtp({ token_hash, type: 'magiclink' }) → invalidIt fails only for signup, only on some clients, and produces an error indistinguishable from a genuinely bad token. Reading both spellings costs one line and removes a defect nobody could have diagnosed from the outside.
The test is about the machine, not the person
That sign-in works proves nothing; it worked before. The property to assert is that sign-in survives being scanned, which is three statements and fails loudly against the old design:
const { hashed_token } = await adminGenerateLink({ type:'signup', email })
const url = `${APP}/auth/confirm?token_hash=${hashed_token}&type=signup`
// 1. the scanners go first
for (let i = 0; i < 3; i++) await fetch(url, { redirect:'manual' })
// 2. rendering alone must not authenticate
await page.goto(url)
expect(await sessionInStorage(page)).toBe(false)
// 3. the human still gets in, after all three
await page.getByRole('button', { name:/sign me in/i }).click()
expect(await sessionInStorage(page)).toBe(true)Step 2 is the one worth keeping. Without it the suite passes on a page that verifies in an effect on mount, which is the same bug wearing a button.
What we can and cannot claim
Everything above is a mechanism we reproduced end to end against a live system. That is not the same as having established it as the cause of any particular stranded account, and the distinction is worth holding onto, because a mechanism this tidy is exactly the kind of thing that ends an investigation early.
When we went looking for the fingerprint — accounts that never completed sign-in clustering on the corporate mail domains where scanning is most aggressive — it was not there. The distribution ran the other way, weighted toward ordinary consumer webmail. So we have a demonstrated failure mode and an unexplained population, and those are two findings, not one.
- Ship the fix regardless: it is cheap, it is the vendor's own recommendation, and it removes a real failure mode whether or not it is this one.
- Instrument the thing you were blind to. We had no record of a single one of these bounces, because the only observer possible was client-side code nobody had written. Absence of evidence was structural.
- Then wait. A fortnight of that event answers the question the way no amount of reasoning about the mechanism can.
The uncomfortable part is that the story was good enough to stop us looking. It explained the symptom, it was reproducible, the vendor documents it, and every other implementation has hit it. All of that was true and none of it made it the diagnosis.
More from the notebook →