← All posts
·8 min read·GuardLayer

Fix: Invalid Refresh Token: Already Used

SupabaseAuthNext.jsSessions

AuthApiError: Invalid Refresh Token: Already Used is a race condition, not an expired session. Two or more parts of your app refreshed the same refresh token at once — the first consumed it, the rest arrived too late. Fix it by refreshing the session in exactly one place (middleware) and letting everything downstream read the cookies it wrote.

The error reads like an expiry problem, so that's where everyone starts: bumping the JWT lifetime, adding retry logic, calling signOut() in a catch block. None of it helps, because nothing expired. A refresh token was spent, and a second request tried to spend it again. (If your error says Refresh Token Not Found instead, that's the opposite failure — the rotated token was never persisted at all.)

What "Already Used" actually means

Supabase rotates refresh tokens. Every successful refresh returns a new refresh token and retires the old one. That rotation is a theft-detection mechanism: if a retired token shows up again, that's evidence someone copied it, so the whole session gets revoked.

To avoid punishing normal network flakiness, Supabase's session docs carve out two exceptions:

A refresh token can be used more than once within a defined reuse interval. By default this is 10 seconds and we do not recommend changing this value.

Plus a parent-token rule: if the parent of the currently active refresh token is presented, the active token is returned instead of an error. Outside those two windows, reuse is treated as compromise and every refresh token for the session is revoked — which is why the symptom is often a hard logout rather than a retryable error.

So Already Used means: a refresh happened, and a second refresh with the same token landed outside the 10-second reuse interval and outside the parent-token exception.

Why the Next.js App Router triggers it

The reported reproduction is mundane, which is what makes it hard to catch: a server client created in more than one place, all rendering concurrently.

An App Router request doesn't run one file. Middleware runs, then the root layout, then a nested layout, then the page — and layouts at the same level render in parallel. If each of those creates its own createServerClient and calls getUser(), you have several independent Supabase clients, each holding the same cookie, each deciding the access token is stale, each POSTing to /auth/v1/token?grant_type=refresh_token at roughly the same moment.

One wins. It writes a new token pair to the response cookies. The others were already in flight with the old token. They get Already Used.

Three things make it worse in practice:

  • It's timing-dependent. It fires under load, on cold starts, after a laptop wakes — never when you're looking for it.
  • Server clients can't see each other's writes. Two server clients in the same request don't share an in-memory lock the way the browser client does.
  • Server and browser can collide too. A tab that was backgrounded for an hour wakes up and refreshes on its own timer; if a server render is refreshing at the same moment, they're two independent processes spending the same token.

How do I fix Invalid Refresh Token: Already Used?

Refresh in exactly one place. Middleware is that place: it runs first, it can write cookies on the response, and everything downstream in the same request inherits what it wrote. Every other server client should read the session, never renew it.

The canonical middleware — note that getUser() is called and its result used, which is what performs the refresh and writes the rotated cookies:

On Next.js 16.1+ this file is proxy.ts, not middleware.ts. The convention was renamed: the file becomes proxy.ts and the exported function becomes proxy. config.matcher is unchanged, and npx @next/codemod@canary middleware-to-proxy does the rename for you. Everything below applies identically — only the filename and function name differ.

// middleware.ts (proxy.ts on Next.js 16.1+)
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";

export async function middleware(request: NextRequest) {
  let response = NextResponse.next({ request });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => request.cookies.getAll(),
        setAll: (cookiesToSet) => {
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value)
          );
          response = NextResponse.next({ request });
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options)
          );
        },
      },
    }
  );

  // The one refresh point for the whole request.
  const { data: { user } } = await supabase.auth.getUser();

  if (!user && !request.nextUrl.pathname.startsWith("/login")) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  return response;
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg)$).*)"],
};

Two details that people get wrong here and then blame the refresh token for:

  1. You must return response. If you return a fresh NextResponse.next() you discard the rotated cookies, so the next request presents the old token — and now you are reusing a spent token, on every single request.
  2. The setAll dance is not boilerplate noise. Writing to request.cookies before rebuilding the response is what makes the new token visible to the rest of this request. Skip it and your layouts refresh again.

Then stop refreshing anywhere else. In layouts, pages, and Route Handlers, create the client from the request cookies and read. One getUser() per request is the target; if you need the user in five components, resolve it once in the layout and pass it down, or wrap it in React's cache() so repeat calls dedupe.

There is no per-client opt-out to reach for here. createServerClient already forces autoRefreshToken: false internally and overrides a caller-supplied persistSession, and neither flag would help anyway — the refresh that collides isn't the background timer, it's the on-demand one getUser() performs when it finds an expired access token. Call count is the only lever.

The fix that quietly removes your auth

Here's the workaround that shows up in every thread on this error, because it genuinely makes the message disappear:

// Swapped in to stop the concurrent refreshes. It also stops checking anything.
const { data } = await supabase.auth.getSession();
if (!data.session) {
  return NextResponse.redirect(new URL("/login", request.url));
}
guardlayer scan · middleware.tsLive engine output
Passed with warnings
92/100 · A
  • Warningmiddleware.ts:21

    getSession() trusted in server code

    In server code (middleware, route handlers, server actions) authorize with supabase.auth.getUser() — it revalidates the JWT — not getSession(). getSession() is fine on the client, where the session is already trusted.

getSession() stops the error because it never contacts the Auth server, so it can never race. It also never validates anything. On the server it decodes whatever JWT is sitting in the request cookies and hands it back — it does not verify the signature, so a forged or tampered cookie passes the check. You've turned a noisy race condition into a silent authentication bypass in your middleware, which is the one file where every protected route's gate lives.

The distinction matters more than it looks: getUser() revalidates the token with Supabase Auth and is safe to authorize on; getSession() is fine on the client, where the session is already trusted, and unsafe as a server-side gate. GuardLayer flags this exact pattern in middleware.ts because it reads as a working auth check right up until someone edits a cookie.

Quick self-check

# How many places create a server client and refresh?
grep -rn "createServerClient" --include=*.ts --include=*.tsx app/ lib/ middleware.ts proxy.ts

# Every getUser() call is a potential refresh. One per request is the target.
grep -rn "auth.getUser()" --include=*.ts --include=*.tsx app/ lib/ | wc -l

# Any getSession() being used as a gate on the server?
grep -rn "auth.getSession()" --include=*.ts --include=*.tsx app/ lib/ middleware.ts proxy.ts

If the second number is larger than the number of routes you have, you have a refresh storm waiting for a slow day. If the third returns hits in your middleware/proxy file or a Server Action, fix that before you fix the race.

FAQ

What causes "Invalid Refresh Token: Already Used" in Supabase? A refresh token was consumed by one request and presented again by another, outside the 10-second reuse interval and outside the parent-token exception. In Next.js this is almost always concurrent App Router layouts each running their own getUser().

Does raising the JWT expiry fix it? No. The access token's lifetime isn't involved. A longer expiry just makes refreshes rarer, so the race fires less often — the bug is still there.

Should I increase the refresh token reuse interval? Supabase explicitly recommends against changing it. Widening the window weakens reuse detection, which is the thing protecting you from a stolen token being replayed. Fix the concurrency instead.

Why does it log the user out completely? Because reuse outside the allowed exceptions is treated as a stolen-token signal, and Supabase revokes every refresh token for that session. That's deliberate — a full logout is the safe response to suspected theft.

Is switching to getSession() a valid workaround? It stops the error but removes the verification. On the server getSession() doesn't check the JWT signature, so anything gated on it can be spoofed with a crafted cookie. Keep getUser() and reduce how often it runs.

I still get logged out roughly once a day. Same bug? Probably not — that pattern usually points at cookies being dropped or overwritten rather than raced, such as leftover chunked auth cookies shadowing the live session, or a middleware that never writes the refreshed cookie back. Check whether the error string is Already Used or something else before treating it as this bug, and confirm your session isn't simply expiring unrefreshed.

Catch this before it ships — free

GuardLayer scans every push for this and 28 other Next.js + Supabase issues, with the exact fix inline.

No signup, no card — your code is scanned in memory and never stored.

Keep reading

A Solvion project — see also Reglog — EU AI Act changelog, Proceedly, Solenna and Solvion Solutions.