DEV Community

Joel
Joel

Posted on

Our password reset links kept expiring. The bug only happened if you had two tabs open.

A user told us their password reset link was expired. It was about four minutes old.

I clicked it myself. Worked fine. Asked them to try again. Expired again.

That went on longer than I want to admit, because the bug does not reproduce unless you already have the app open in another tab. I never do while testing. Our users always do.

What was actually happening

Supabase sends the session back in the URL fragment. You land on something like:

https://app.example.com/#access_token=eyJhbG...&type=recovery
Enter fullscreen mode Exit fullscreen mode

The client reads that fragment, exchanges it for a session, and strips it from the URL. The important word is asynchronously. It does not happen on the same tick your app boots.

We had a bit of code at boot that tidied the URL. Nothing exotic:

// what we had
window.history.replaceState(null, '', window.location.pathname)
Enter fullscreen mode Exit fullscreen mode

That runs on mount. If it wins the race, the fragment is gone before Supabase ever reads it. No session, no recovery event, and the reset page renders "That link has expired" on a link that was perfectly valid a second ago.

Why the second tab matters

This is the part I found genuinely interesting.

supabase-js coordinates token refresh across tabs using navigator.locks. With one tab there is no contention and the exchange finishes fast, usually before React has even mounted. Our replaceState lost the race and everything worked.

Open a second tab and the lock is contended. The exchange waits. In our logs GoTrue was saying:

Lock not released within 5000ms
Enter fullscreen mode Exit fullscreen mode

It recovers, but by then it is whole seconds behind. Our replaceState had fired ages ago and eaten the token.

So the bug was not intermittent at all. It was deterministic. It just depended on a variable I was not thinking of as a variable.

Worth saying plainly, because it cost me an afternoon: "I cannot reproduce it" usually means my environment differs from theirs in a way I have not identified yet. It rarely means the user is wrong.

We had two of them

Once I knew what to look for, there was a second offender. A boot routine that normalised query parameters, on a blind 1.5 second timer. Same effect, slightly later, and it explained why a couple of the fixes I tried seemed to half work.

If you have written one URL scrub you have probably written two. Grep for replaceState and pushState before you decide you are done.

The rule that fixed it

Nothing may rewrite the URL while the fragment still carries unconsumed tokens.

In practice that means one of two things. Either wait until the hash is empty, or only touch your own markers and put the fragment back exactly as you found it. We went with the second because it does not need a timer at all:

useEffect(() => {
  if (!callbackType) return
  const { pathname, search, hash } = window.location

  if (search.includes('recovery=1')) {
    const params = new URLSearchParams(search)
    params.delete('recovery')                 // our marker, ours to remove
    const qs = params.toString()
    window.history.replaceState(
      null, '',
      `${pathname}${qs ? `?${qs}` : ''}${hash}`   // fragment goes back untouched
    )
  }
}, [])
Enter fullscreen mode Exit fullscreen mode

Supabase strips the fragment itself once it has consumed it. You do not need to clean up after it. Let it finish.

We also kept a second net, because the exchange can land after mount:

supabase.auth.onAuthStateChange((event) => {
  if (event === 'PASSWORD_RECOVERY') {
    setCallbackType('recovery')
    window.history.replaceState(null, '', window.location.pathname)  // safe now
  }
})
Enter fullscreen mode Exit fullscreen mode

That replaceState is fine, because by the time the event fires the token has already been used.

The bit that bites you next

There is a follow-on problem that is easy to walk straight into once you have fixed the first one.

If anything rewrites the URL at boot, then any code reading window.location later is reading whatever survived, not what the user arrived with. We were checking for a recovery marker after our own scrub had already removed it.

The fix is to snapshot at module evaluation, before React does anything:

const bootHash = typeof window !== 'undefined' ? window.location.hash : ''
const bootSearch = typeof window !== 'undefined' ? window.location.search : ''
Enter fullscreen mode Exit fullscreen mode

Then read bootSearch and bootHash everywhere, never live location. Same idea applies to UTM parameters, referral codes, anything you care about that arrives in the URL and might get tidied away.

We also route expired links to a real screen with a resend button instead of dumping people on the dashboard with no explanation. Some of those links genuinely are expired, and "nothing happened" is a terrible answer.

What I would take from it

If you are using Supabase auth in a SPA, go and grep for replaceState right now. If any of it runs at boot without checking the hash, you have this bug. It will look intermittent, it will not reproduce on your machine, and it only shows up on the flow where users are already frustrated.

Two tabs. That was the whole thing.


I build SOCIALFUEL, which pulls the live ads any brand is running and breaks down why they work. This one came out of our own auth flow, and I would rather write it up than have someone else lose the same afternoon.

Top comments (0)