Skip to content

Commit 05d50ec

Browse files
fix(auth): clear stale cookies on refresh token failure to stop infinite retry loop
When a user visits the login page with an expired/revoked refresh token, the browser Supabase client retries token refresh endlessly (400 → 429). Two fixes: - middleware updateSession(): properly detect getUser() auth errors (returned, not thrown) and clear all sb-* cookies via Set-Cookie maxAge=0 - proxy.ts: use getSetCookie() instead of get('set-cookie') to copy ALL Set-Cookie headers to the response (was only copying the first one, losing cookie-clearing headers for chunked auth tokens) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 278c910 commit 05d50ec

2 files changed

Lines changed: 27 additions & 17 deletions

File tree

lib/supabase/proxy.ts

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export async function updateSession(request: NextRequest): Promise<{ response: N
4545
)
4646

4747
// Do not run code between createServerClient and
48-
// supabase.auth.getClaims(). A simple mistake could make it very hard to debug
48+
// supabase.auth.getUser(). A simple mistake could make it very hard to debug
4949
// issues with users being randomly logged out.
5050

5151
// IMPORTANT: Only call getUser() when auth cookies exist.
@@ -58,19 +58,25 @@ export async function updateSession(request: NextRequest): Promise<{ response: N
5858
return { response: supabaseResponse, user: null }
5959
}
6060

61-
try {
62-
const { data } = await supabase.auth.getUser()
63-
user = data.user
64-
} catch (e: any) {
65-
// If refresh token is invalid/expired, clear auth cookies so we stop retrying
66-
if (e?.code === 'refresh_token_not_found' || e?.message?.includes('Refresh Token Not Found')) {
67-
const cookieNames = request.cookies.getAll()
68-
.map(c => c.name)
69-
.filter(name => name.startsWith('sb-'))
70-
for (const name of cookieNames) {
71-
request.cookies.delete(name)
72-
supabaseResponse.cookies.delete(name)
73-
}
61+
const { data, error } = await supabase.auth.getUser()
62+
user = data.user
63+
64+
// When the refresh token is invalid/expired, getUser() returns an error with no user.
65+
// The @supabase/ssr setAll callback may have already fired with the failed session,
66+
// but we need to explicitly clear all sb-* cookies from the response so the browser
67+
// deletes them. Without this, the browser's Supabase client will see stale cookies
68+
// and start an infinite refresh_token retry loop (400 → 429 → 400...).
69+
if (error && !user) {
70+
// Clear cookies from both the request (for downstream server components)
71+
// and the response (for the browser via Set-Cookie headers).
72+
const sbCookies = request.cookies.getAll().filter(c => c.name.startsWith('sb-'))
73+
for (const { name } of sbCookies) {
74+
request.cookies.delete(name)
75+
supabaseResponse.cookies.set(name, '', {
76+
maxAge: 0,
77+
path: '/',
78+
...(cookieDomain ? { domain: cookieDomain } : {}),
79+
})
7480
}
7581
}
7682

proxy.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -278,9 +278,13 @@ export default async function proxy(request: NextRequest) {
278278
return NextResponse.redirect(dashboardUrl)
279279
}
280280

281-
const supabaseCookies = supabaseResponse.headers.get('set-cookie')
282-
if (supabaseCookies) {
283-
intlResponse.headers.set('set-cookie', supabaseCookies)
281+
// Copy ALL Set-Cookie headers from supabaseResponse to intlResponse.
282+
// headers.get('set-cookie') only returns the first header — use getSetCookie()
283+
// to get all of them. This is critical when clearing multiple sb-* cookies
284+
// (e.g., auth token + chunked tokens) to stop the client-side refresh loop.
285+
const setCookieHeaders = supabaseResponse.headers.getSetCookie()
286+
for (const cookie of setCookieHeaders) {
287+
intlResponse.headers.append('set-cookie', cookie)
284288
}
285289
return intlResponse
286290
}

0 commit comments

Comments
 (0)