Back to Projects

The Scanner Clicked First

Alex Wilson••6 min read•Builder Journal
Brass key beside an open empty envelope as a scanner beam sweeps across it

This week I shipped a small private web app, invite-only, for sharing works in progress with a handful of people. The first real person I invited was my friend Randy. Randy tapped the link in the invite email and got this back:

"That link didn't work."

I had tested the invite flow myself, more than once, on my phone and my desktop. It worked every time. It failed for exactly one person, on the first try that mattered.

Three seconds

The auth logs told the whole story in two lines:

19:14:06  HEAD /auth/confirm?token_hash=…&type=invite   → session created
19:14:09  GET  /auth/confirm?token_hash=…&type=invite   → token rejected

Somebody opened that link three seconds before Randy did, and it wasn't Randy.

Randy uses Hotmail. Outlook and Hotmail run a feature Microsoft calls Safe Links: when an email arrives, or when it's opened, the mail service fetches every link in it to check for phishing and malware. That fetch is a real HTTP request to the real URL. My /auth/confirm route didn't know the difference between a security crawler and a human. It saw a valid token, verified it, created a session for a client that was never going to use it, and marked the token spent.

Three seconds later the actual human arrived with a token that was already used. From the server's point of view, Randy was the replay attack.

Why my testing never caught it

The confirm route was a plain GET handler. Someone hits the URL, the handler calls verifyOtp with the token hash, and on success it redirects to "choose a password." That is the shape almost every Supabase starter ships with, and it's the shape I had copied forward from my other app, HeadstringWeb, where it had been running for months.

Every account I tested with was on Gmail or my own domain. None of them pre-fetch links. The bug needed three things at once: a one-time token, a GET that spends it, and a recipient whose mail provider clicks before they do. My test matrix had the first two and never the third.

I'd also been carrying an assumption I never wrote down: a GET request is a person looking at something. HTTP has said for a long time that GET and HEAD are supposed to be safe, meaning no side effects. Spending a single-use credential is about as side-effecty as it gets. Safe Links behaves well under the spec. My route was the one breaking it.

The fix is a button

The change was smaller than the diagnosis. /auth/confirm stopped being a route handler and became a page. Opening the link now does nothing except render a card with one button: "Continue." The token rides along in hidden form fields. Pressing the button submits to a server action, and the server action is the only place that calls verifyOtp or exchangeCodeForSession.

export async function confirmEmailLink(form: FormData): Promise<never> {
  const tokenHash = field("token_hash");
  const type = field("type") as EmailOtpType;
  if (!tokenHash || !type) fail("That link is incomplete.");
  const { error } = await supabase.auth.verifyOtp({ type, token_hash: tokenHash });
  if (error) fail("That link didn't work. Request a new one.");
  redirect(next);
}

Scanners follow links. They don't fill in forms and press buttons. So the crawler gets a harmless page, the token stays intact, and the person who actually wants in spends it with a POST.

One detail I'm glad I noticed: I kept the exact same URL. The invite already sitting in Randy's inbox pointed at /auth/confirm, and so did every other link already sent. Because the path didn't change, those old emails were fixed the moment the deploy landed. Randy tapped the same link again and got in.

The cost is one extra tap for every invite and password reset. I'll take that trade every time over a login flow that works or doesn't depending on the recipient's mail provider.

Same bug, other app

The obvious next question was where else I'd written this route. The answer was HeadstringWeb, the site builder where my clients get their invites and password resets. Same GET handler, same verifyOtp on first touch, and a client base that is a lot more likely to be on Outlook than my musician friends are. Nobody had reported it, which mostly tells me those clients requested a second link and never mentioned the first one.

I ported the fix the same afternoon, and porting it shook loose two more problems sitting in the same file:

  • An open redirect. The old route honored a next parameter as long as it started with /. A protocol-relative URL like //evil.example starts with / too, and browsers treat it as "go to that host." The new helper accepts a same-site path only and rejects anything starting with // or /\.
  • An error page that hid every error. The auth flow redirected failures to /auth/error?message=…, but the error page only read ?error=. So every failure, expired link or reused token or anything else, displayed "An unspecified error occurred." The real message had been in the URL the whole time, one parameter name away from the screen.

I verified the HSW version locally against the exact sequence that broke Randy's invite: a HEAD and a GET from a fake scanner, then confirmed the token was still valid, pressed Continue, and landed on the update-password page. A reused link now shows a readable error. next=//evil.example is ignored.

What I'm keeping from this

The rule I'm holding onto is short: a GET never spends anything. Magic links, invite tokens, password resets, unsubscribe links, "approve this" buttons in email. If the URL does something irreversible on first touch, a crawler will eventually touch it first. Mail scanners, link previewers in chat apps, and browser prefetch all make requests that look like a person and aren't.

The other thing I'm keeping is how this got found. It didn't come from a test suite or a code review. It came from the first real user on a mail provider I don't use, and from two timestamps three seconds apart in a log I almost didn't open. "Works on my accounts" turned out to be a much narrower statement than I thought it was.

Share: