DEV Community

Yaroslav
Yaroslav

Posted on

Email scanners were confirming our users' accounts before they clicked

Our sign-up emails contain a link. You click it, your account is confirmed, you land in the app. That's the whole feature, and for a while it was failing in a way that looked like three different bugs.

It was one. The email was being opened by a robot before the person got to it.

The symptom

Some people clicked the confirmation link and got an error. Not always, and not for everyone. When the error showed up it said the link had expired or was invalid, although the email was a few minutes old.

The stack is Next.js (App Router), Supabase Auth and Resend for email. The default Supabase confirmation link points straight at its /auth/v1/verify endpoint, which verifies the token on any GET request. Keep that sentence in mind.

Three fixes that fixed nothing

We went after what we could see, one thing at a time:

  1. An error that arrived in a URL fragment, which the server never sees, so the app showed nothing useful for it.
  2. People landing on the wrong screen after confirming.
  3. A raw, unfriendly error message.

Each of those was a real problem and each fix was reasonable. The links still failed. All three were treating the symptom. None of them touched whatever was using up the token.

The evidence that mattered

The thing that broke the case open was a timestamp. The account's confirmed_at was filled in about two minutes after we sent the email, and nobody had clicked anything. We confirmed this against a real Outlook mailbox: send a sign-up email, do not open it, watch the row.

So something opened the link on its own. Mail systems scan links in incoming messages for security, which means they fetch the URL and follow it. If the URL verifies the token on a plain GET, the scanner has just verified the account. By the time the person clicks, the single-use token is gone and they get otp_expired.

The error message was telling the truth. It just wasn't describing what we thought it was.

The fix: wait for a real click

Any token delivered by email has to land on a page of your own, and it must be redeemed only by a real user gesture. Not on load. Not in an effect. Not by a form that submits itself.

A scanner can fetch that page and even render it. It doesn't press the button. So our confirmation link now points at our own page, which shows a button, and only the click calls the server.

A simplified sketch of the pattern (not our production code):

// The email links to /auth/confirm?token_hash=...&type=email
// This page does NOT verify anything on load.
"use client"

import { confirmToken } from "./actions"

export function ConfirmInterstitial({ tokenHash, type, email }: Props) {
  return (
    <button onClick={() => confirmToken({ tokenHash, type, email })}>
      Confirm my email
    </button>
  )
}
Enter fullscreen mode Exit fullscreen mode
// actions.ts
"use server"

export async function confirmToken({ tokenHash, type, email }: Input) {
  const supabase = await createClient()
  const { error } = await supabase.auth.verifyOtp({ token_hash: tokenHash, type })
  // ...
}
Enter fullscreen mode Exit fullscreen mode

The email template also must not point at /auth/v1/verify any more, since that endpoint treats any GET as a verification.

A second trap on the way

verifyOtp returns the same otp_expired for a garbage token and for a once-valid token on an account that is already confirmed. So someone who clicks twice, or whose scanner got there first, sees an error even though their account is fine. For sign-up links we added a small check that asks whether the account is already confirmed, and shows "Your email is already confirmed" instead of an error. Password reset and email change don't have that in-between state, so they handle failure as a plain error.

What I'd tell myself earlier

Diagnose from timestamps and state, not from the wording of the error screen. We spent four attempts fixing what the screen described. The row in the database already said what had happened.

What I haven't verified

I confirmed this against an Outlook mailbox. Other providers and security tools may behave the same way, and probably some do, but I haven't tested them, so I'm not going to say which. I also haven't counted how many people were affected.


Disclosure: I'm the founder of Sesly, a small CRM for solo coaches. This is how our own sign-up flow works.

Top comments (2)

Collapse
 
elijahbrown profile image
Elijah Brown •

The confirmed_at timestamp two minutes after send is a great tell. One knock-on worth checking: any "confirmed signups" number from before the fix is inflated, because the scanner was confirming addresses no person ever opened. If you use confirmation as a trust signal for free credits or trials, those accounts got the benefit without anyone proving they own the inbox. Might be worth re-flagging accounts confirmed within a minute or two of the send and asking them to confirm again through the new button page.

Collapse
 
ahmedznouda profile image
Ahmed ZNOUDA •

The confirmed_at timestamp is such a clean piece of evidence. One thing I'd keep an eye on: some sandbox-style scanners don't stop at fetching the URL, they render the page and run its JavaScript. Whether any of them also press buttons I honestly don't know, so the interstitial may hold up fine. If it ever doesn't, the one step no scanner can do is typing a code. Supabase templates can include {{ .Token }}, the six-digit OTP, and the person enters it on the sign-up screen they already have open.

That also covers a second case: someone signs up on a laptop and taps the link on their phone. With the button page, the session lands on the phone and the laptop tab still says "check your email". Do you show anything on the original tab when the confirmation happens on another device?