Supabase "Invalid Refresh Token: Refresh Token Not Found": why are people logged out?

Checked on

In plain words: your app tried to reuse a sign-in pass that only works once, so the user gets logged out. Supabase refresh tokens are meant to be used once. This error means your app tried to use one that was already used, revoked or deleted. The three most common causes: signOut() without a scope (it signs the user out everywhere by default), two parts of your app refreshing the same session at once (browser and server middleware), or an old @supabase/ssr version. Use signOut({ scope: 'local' }), upgrade @supabase/ssr, and make sure only one place refreshes the session per request.

Symptoms

  • Logs show AuthApiError: Invalid Refresh Token: Refresh Token Not Found.
  • Users get logged out at random, often after about an hour (when the access token expires and the app tries to refresh).
  • Signing out on a phone logs the same user out on their laptop too.
  • It happens in a Next.js app with server-side auth (middleware or route handlers), less often in a pure single-page app.
  • On localhost, after resetting or switching the database, old cookies keep producing the error.

Why it happens

A Supabase session is a short-lived access token (a JWT) plus a refresh token. Supabase's docs say refresh tokens "never expire but can only be used once" (Supabase: User sessions). There's a short reuse window, 10 seconds by default, for legitimate cases such as server-side rendering. Outside it, reusing an old token counts as a possible theft: "the whole session is regarded as terminated and all refresh tokens belonging to it are marked as revoked."

The common causes:

  1. Global sign-out. In JavaScript, signOut() defaults to the global scope. Supabase's docs say this "revokes the refresh tokens for every session that user has, including other devices and browsers. Those other sessions fail with AuthApiError: Invalid Refresh Token: Refresh Token Not Found the next time they try to refresh" (Supabase: Signing out).
  2. Two refreshes racing. A browser tab and your SSR middleware both try to refresh the same session at the same moment. Refresh tokens are single use, so one side loses. Supabase's maintainers describe this in supabase/ssr#68.
  3. An old @supabase/ssr. A background initialization bug made the server client refresh on its own and race your getUser() call. Per a Supabase maintainer in supabase/ssr#68, it "was fixed in @supabase/ssr@0.8.1".
  4. A stale token on the server. Supabase's SSR guide says the most likely reason is that "the refresh token sent from the browser to your server is stale" (Supabase: Advanced SSR guide).
  5. Session limits you set. Time-boxed sessions, an inactivity timeout or "single session per user" in Auth settings end sessions on purpose. They're enforced at the next refresh (Supabase: User sessions).

Step-by-step fix

1. Fix sign-out

Find every supabase.auth.signOut() call. Unless you really want "log out of all devices", pass the local scope:

await supabase.auth.signOut({ scope: 'local' })

2. Upgrade the Supabase packages

npm install @supabase/ssr@latest @supabase/supabase-js@latest

If you're on a Next.js app, check that @supabase/ssr is at least 0.8.1 afterwards.

3. Refresh in one place per request

Follow Supabase's current Next.js middleware setup (Supabase: Creating a client for SSR). A Supabase maintainer's advice in supabase/ssr#68: don't call getUser(), getClaims() or getSession() from both middleware and an API route on the same request.

4. Create server clients per request

Supabase warns that on Vercel's Fluid compute a client "initialized in module scope, or stored in a shared variable, may be reused across requests from different users" (Advanced SSR guide). Build the server client inside each request handler. Create the browser client once and reuse it.

5. Register onAuthStateChange early

Supabase's SSR guide says to make sure the onAuthStateChange listener "is free of bugs and is registered relatively early in your application's lifetime." When the server hits this error, let the browser handle the session (it has the freshest token) instead of crashing the page.

6. Leave reuse detection on

You can turn off refresh-token reuse detection in Auth settings, but Supabase says this is "generally not recommended" (User sessions). Fix the race instead. (Self-hosted only: a maintainer suggests setting GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL to a few seconds, because self-hosted projects default the grace window to off.)

7. Handle the error gracefully

If a refresh still fails, sign the user out locally and send them to your login page with a short message, instead of showing a broken page.

Fix prompt for your AI builder

Paste this into Lovable, Bolt, Cursor, Replit, v0 or whichever tool you build with:

Fix prompt
Users get logged out with "AuthApiError: Invalid Refresh Token: Refresh Token Not Found". Fix only session handling:
1. Change every supabase.auth.signOut() call to signOut({ scope: 'local' }) unless it's an explicit "log out of all devices" button.
2. Upgrade @supabase/ssr and @supabase/supabase-js to the latest versions (ssr must be 0.8.1 or newer).
3. If this is Next.js: make the middleware match Supabase's current SSR docs, create the server client inside each request (never in module scope), and don't call getUser/getSession in both middleware and the API route for the same request.
4. Create the browser Supabase client once and reuse it. Register onAuthStateChange early.
5. If a refresh fails, sign out locally and redirect to /login with a friendly message.
List the files you changed. Don't change the database, RLS policies or UI design.

How to confirm it's fixed

  1. Log in on two browsers with the same test account. Log out in one. The other should stay logged in.
  2. Lower the JWT expiry temporarily in a test project (Supabase doesn't recommend going below 5 minutes), keep a tab open past it, and check that the session refreshes without errors.
  3. Watch your server logs for a day for the error string.

How to find out sooner next time

Heedza won't catch this one directly: it never signs in, so it never holds a session. What it does is check on a schedule that your login and signup pages still load and show their forms, and send you a screenshot and a fix prompt when they don't. That's useful if a session fix like this one accidentally breaks the login page itself. The free scan also checks your homepage for exposed keys and missing security headers.

Let Heedza check this for you. Heedza’s log-in check keeps opening your live log-in page and tells you if it stops opening or loses its form. It never logs in or fills in forms, so it can’t see errors that only show after that.

Made with Lovable, Bolt, Replit, Cursor or v0? Free for 1 site: start free or see prices.

More questions

Why only after about an hour?

That's when the access token expires and the app first needs the refresh token. Supabase's docs say access tokens usually last between 5 minutes and 1 hour.

Should I set cookies to expire sooner to be safe?

No. Supabase recommends long cookie lifetimes and letting Supabase Auth control token validity (User sessions).

It only happens on localhost.

If you reset or switched the database, old cookies point at sessions that no longer exist. Clear the site's cookies.

Is someone attacking my users?

Probably not. Reuse detection exists to guard against stolen refresh tokens, but app bugs like the ones above trigger it far more often. If you see it at scale with no code change, check Auth logs.

Where this comes from