Fix: Invalid Refresh Token: Refresh Token Not Found
AuthApiError: Invalid Refresh Token: Refresh Token Not Found means the refresh token the browser just sent no longer exists in Supabase's database — almost always because your Next.js middleware refreshed the session but never wrote the rotated cookie back to the response the browser actually receives. Fix it by returning the exact NextResponse object whose cookies setAll mutated.
The tell is the symptom, not the stack trace. Users don't complain about an error — they complain that they get logged out roughly once a day, or that clicking a magic link drops them into a login loop. Everything works fine for an hour, then works fine for another hour, and then one morning the session is simply gone.
That pattern is reported verbatim in @supabase/ssr issue #68, which is still open:
"AuthApiError: Invalid Refresh Token: Refresh Token Not Found when refreshing token in middleware" — users unexpectedly logged out of a Next.js app roughly every 24 hours. The reporter notes that artificially shortening JWT expiry to 60 seconds makes refresh work correctly, which points away from the token and toward the plumbing around it.
What "Refresh Token Not Found" actually means
A Supabase session is two tokens. The access token is a short-lived JWT — the default expiry is 1 hour. The refresh token is a long-lived opaque string used to mint a new access token once the old one expires.
Refresh tokens rotate. Supabase's docs are explicit that a refresh token can only be used once: each successful refresh issues a new refresh token and retires the one you presented. There is a grace window — the refresh token reuse interval, 10 seconds by default — during which replaying the immediately-previous token returns the currently-active session instead of failing, so a dropped network response doesn't destroy the session.
That leaves exactly two ways to get Refresh Token Not Found:
- You presented a token that was rotated away more than 10 seconds ago, and its replacement never made it into the browser's cookie jar.
- You presented a token from a session that was revoked outright — sign-out everywhere, user deleted, or the project's JWT secret rotated.
Cause 1 produces the "logged out every day" report. Cause 2 produces an instant, obvious logout that nobody files an issue about.
This is a different failure from Invalid Refresh Token: Already Used, which is a race condition between concurrent refreshes. "Already Used" means Supabase found the token and it had been spent. "Not Found" means Supabase has no record of it at all — the rotation happened, and your app discarded the result.
Why does Supabase log me out every 24 hours?
Because the rotated refresh token is written to a response object that never reaches the browser. Your middleware refreshes correctly on the server, Supabase issues a new token, setAll stores it — on a NextResponse you then throw away by returning a different one.
Here is the shape of the bug. It looks entirely reasonable:
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
const 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: (list) =>
list.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
),
},
}
);
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.redirect(new URL("/login", request.url));
}
// BUG: a brand-new response. Every Set-Cookie header the refresh
// just produced was written to the OLD `response` and is now gone.
return NextResponse.next({ request });
}
Note the timing, because it explains why this hides for so long. getUser() only triggers a refresh when the access token has actually expired — at most once an hour, and often only once per user per day depending on traffic. Every request in between works perfectly, because no rotation happens and there is nothing to lose. The bug is invisible until the one request that rotates the token. Then the old token is dead, the new one was discarded, and ten seconds later the reuse interval closes and the session is unrecoverable.
The redirect branch has the same defect: NextResponse.redirect(...) is a fresh response carrying none of the refreshed cookies.
The fix
Return the same response object whose cookies were mutated, and when you must build a new response, copy the cookies onto it:
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
const 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: (list) =>
list.forEach(({ name, value, options }) => {
// Keep the request in sync for downstream handlers...
request.cookies.set(name, value);
// ...and write the Set-Cookie the browser will receive.
response.cookies.set(name, value, options);
}),
},
}
);
// Revalidates the JWT with the Auth server AND refreshes it if expired.
const { data: { user } } = await supabase.auth.getUser();
if (!user && !request.nextUrl.pathname.startsWith("/login")) {
const redirect = NextResponse.redirect(new URL("/login", request.url));
// Carry the refreshed cookies onto the redirect response.
response.cookies.getAll().forEach((c) => redirect.cookies.set(c));
return redirect;
}
return response; // the SAME object setAll wrote to
}
Three rules fall out of that, and between them they cover almost every report of this error:
- One response object per request. If you construct a second
NextResponseafter creating the Supabase client, you must copy the cookies onto it. - Never leave
setAllempty. A no-opsetAll: () => {}compiles, silences the type error, and guarantees this bug. It also meansgetUser()returns null in your Server Components, because nothing downstream ever sees the refreshed session. - Refresh in exactly one place. Middleware is the right place. Refreshing in middleware and in a root layout gives you two concurrent refreshes, which is how you turn this bug into the "Already Used" variant.
Check whether you have this bug
You don't have to wait a day to find out. Drop your project's access token expiry to 60 seconds (Dashboard → Authentication → Sessions), then:
# 1. Does middleware return a response it never wrote cookies to?
grep -n "NextResponse" middleware.ts
# 2. Any no-op cookie writer anywhere in the repo?
grep -rn "setAll: *() *=> *{ *}" .
# 3. Watch the wire: load a page after the token expires and confirm
# a Set-Cookie for sb-<project-ref>-auth-token comes back.
If step 3 shows no Set-Cookie on the request that should have refreshed, you've found it. While you're in there, confirm you aren't also carrying leftover auth-token chunk cookies — stale chunks shadow a valid session and produce the same "randomly logged out" report from a completely different direction.
One more thing worth checking: use getUser(), not getSession(), for the middleware call. getSession() reads the cookie without verifying the JWT, so on the server it's both a weaker refresh trigger and a spoofable basis for an authorization decision.
FAQ
Is "Refresh Token Not Found" the same as "Already Used"? No. "Already Used" means the token existed and had been spent — a concurrency race. "Not Found" means Supabase has no record of the token at all, usually because the rotated replacement was never persisted to the browser.
Why does it happen every ~24 hours instead of every hour? Rotation only occurs on the request that finds an expired access token. Whether that lands hourly or daily depends on how often that user hits your app and how many of those hits pass through middleware.
Could this be caused by the user having multiple tabs open? Multiple tabs cause the "Already Used" race, not "Not Found". If you see both, fix the cookie persistence first — a session that never persists its rotation makes every other refresh problem worse.
Does increasing the refresh token reuse interval fix it? No, and Supabase explicitly recommends against changing it. Widening the window only hides a dropped cookie for a few more seconds; the next rotation fails the same way.
I only see this in production, never locally. Local dev usually means one tab, one origin, and rarely idling past the token expiry. Production has idle tabs, multiple devices, and prefetches, all of which reach the rotation path far more often.
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.