Fix @supabase/ssr 'Detected stale cookie data'
@supabase/ssr: Detected stale cookie data means the library found leftover chunk cookies from an older, larger session that were never deleted — so it may reassemble a broken access token and silently log the user out. The fix is a cookie adapter that implements getAll/setAll (not get/set/remove), on a current @supabase/ssr, plus a one-time purge of the orphaned sb-*-auth-token.N cookies.
The full warning, verbatim from the library:
@supabase/ssr: Detected stale cookie data. Please check your integration with Supabase for bugs. This can cause your users to loose the session.
Yes, "loose" — the typo is in the library, which is genuinely useful: pasting the string into a search box gets you the exact thread you want and almost nothing else. It's reported alongside the Next.js companion error Cookies can only be modified in a Server Action or Route Handler in supabase/ssr#96.
This warning reads like a library bug you can ignore. It isn't. It's the library telling you that your session cookies are in a state it can't trust, and the user-visible symptom — random logouts that you can't reproduce — arrives later, in production, where you'll debug it as an auth bug instead of a cookie bug.
Why does @supabase/ssr chunk cookies at all?
Because a Supabase access token frequently doesn't fit in one cookie. Browsers cap a single cookie at roughly 4 KB. A Supabase session cookie carries the access token, the refresh token, and the user object — and once you add custom claims, OAuth provider tokens, or a fat user_metadata blob, the payload sails past that limit. Push it far enough and the chunks stop fitting in the request header at all, which is how a Supabase login starts returning 431 on Vercel.
So @supabase/ssr splits it:
sb-<project-ref>-auth-token.0
sb-<project-ref>-auth-token.1
sb-<project-ref>-auth-token.2
On read, the library concatenates the chunks in order and parses the result. That works perfectly — as long as the set of chunks on disk matches the set of chunks the current session actually needs.
What causes 'Detected stale cookie data'?
A session shrank, and nobody deleted the now-orphaned trailing chunk. Say the user signs in with Google, so the session is large and occupies three chunks (.0, .1, .2). Later the token refreshes without the provider tokens, and the new session only needs two. A correct adapter writes .0 and .1 and deletes .2. A naive adapter writes .0 and .1 and leaves .2 sitting there.
Now the next read concatenates .0 + .1 + .2 — two chunks of the new session followed by a tail of the old one. The result is malformed JSON, or worse, a plausible-looking token that fails signature verification. The library detects the mismatch, prints the warning, and the session evaporates.
Three integrations produce exactly this state:
- A hand-rolled cookie adapter using the deprecated
get/set/removemethods. The single-cookie API has no way to see the full chunk set, so it physically cannot clean up chunk.2when writing.0and.1. This is the most common cause, and it's usually copy-pasted from a pre-2024 tutorial or generated by an AI coding assistant trained on one. - A
setAllthat writes but never removes. Some adapters loop overcookiesToSetand callcookieStore.set(...), which handles new and changed chunks but never deletes a chunk that's no longer in the list. - A stale
@supabase/ssrversion. The 0.6.x line drew a cluster of reports in issue #96, with the reporter noting that downgrading to 0.5.x made the symptom go away. Chunk handling has been reworked since — being several minors behind is worth ruling out before you debug your own code.
There's a fourth path that's not really your bug: leftovers from @supabase/auth-helpers, which used a different cookie name and chunking scheme. If you migrated, the old cookies can still be in your users' browsers. If you haven't migrated, that package produces its own distinct failure — Failed to parse cookie string ... "base64-eyJ" — because it can't read the current cookie format at all.
The fix
1. Use a getAll/setAll adapter. This is the current, supported shape, and the reason it exists is precisely so the library can see and manage the whole chunk set at once. In middleware.ts:
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() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
// Write to BOTH the request (for this render) and the response
// (for the browser). Skipping either half is the classic bug.
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
);
response = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
);
},
},
}
);
// Refreshes the token and, critically, rewrites the full chunk set.
await supabase.auth.getUser();
return response;
}
Note that setAll receives the complete desired cookie set. When a session shrinks, the removals arrive as entries with an empty value and an expiry that kills the cookie — which is why forwarding options matters. Drop options and the deletion silently becomes a no-op, which puts you right back where you started.
2. Never hand-roll get/set/remove again. If your createServerClient call has three separate cookie functions, that's your bug. Replace it with the block above.
3. Purge the orphans once. Existing users still carry the bad cookies. A defensive sweep on sign-out, or a one-off cleanup in middleware, clears them:
// Delete every auth-token chunk cookie for this project ref.
const ref = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL!).hostname.split(".")[0];
for (const c of request.cookies.getAll()) {
if (c.name.startsWith(`sb-${ref}-auth-token`)) {
response.cookies.delete(c.name);
}
}
This is a blunt instrument: it deletes the current session cookies too, so every user it touches is signed out and has to log back in. Gate it behind a one-shot version cookie so it runs once per browser rather than on every request.
4. Shrink the payload. If your session needs three chunks, you're one custom claim away from this class of bug forever. The two usual culprits are OAuth provider tokens stored in the session and oversized metadata. Both are worth trimming — and if you're storing authorization data there, note that user_metadata is user-editable anyway, so it shouldn't be carrying roles.
Is this a security problem or just a bug?
Mostly a reliability bug — but it has an auth blast radius, so it belongs on the same list as your other session issues.
What it does not do is authenticate the wrong person. A reassembled-from-stale-chunks token has a broken signature, and getUser() revalidates against the Auth server, so a corrupted token fails closed. That's the correct outcome.
What it does do is push people toward bad workarounds. The threads are full of "I just switched to getSession()", or "I disabled the middleware refresh", or "I stored the token in localStorage instead" — and each of those trades a logout bug for a real vulnerability. getSession() on the server reads the cookie without verifying the JWT, which is exactly the wrong trust boundary. If your session handling is unreliable, fix the cookies, not the verification.
Quick self-check
# 1. Deprecated single-cookie adapter anywhere?
grep -rn "remove(name" --include=*.ts --include=*.tsx . | grep -i cookie
# 2. Are you forwarding options in setAll?
grep -rn "setAll" -A 8 --include=*.ts . | grep -n "options"
# 3. What version of @supabase/ssr are you actually on?
npm ls @supabase/ssr
In the browser, open DevTools → Application → Cookies and count the sb-*-auth-token.N entries. If the highest index keeps climbing across sessions, or a .2 persists after a fresh sign-in that only writes .0 and .1, you've reproduced it.
GuardLayer scans the rest of your Next.js + Supabase auth surface — server code trusting getSession(), unmaintained auth dependencies, keys in the wrong place — on every push, so the cookie fix doesn't get quietly undone next sprint.
FAQ
What does "Detected stale cookie data" mean in @supabase/ssr?
The library read a set of chunked sb-*-auth-token.N cookies that don't form a coherent session — typically a leftover trailing chunk from a larger, older session. It warns because reassembling them produces a broken token and drops the session.
Will upgrading @supabase/ssr fix it on its own?
Sometimes, but don't rely on it. If your cookie adapter still uses get/set/remove, or your setAll ignores the passed options, you'll keep generating orphaned chunks no matter the version.
Why does this only happen to some users? It needs a session that shrinks across a refresh. Users who signed in with an OAuth provider, or who have larger metadata, cross the 4 KB chunk boundary; users with small sessions never hit the trailing-chunk case at all.
Can I just clear cookies and move on? That fixes one browser. Every user whose session crosses a chunk boundary will hit it again until the adapter is corrected.
Is this related to "JWT expired" errors? They're neighbours, not the same thing. PGRST301 / JWT expired means nothing refreshed the token; stale cookie data means the refresh happened but the write-back left garbage behind.
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.