Bug Description
The recommended Supabase SSR middleware pattern calls supabase.auth.getUser() on every request to refresh the auth token. Since Supabase refresh tokens are single-use, this creates a race condition that silently kills user sessions.
The Problem
- Browser has cookies with access token (expired or near-expiry) + refresh token
- Middleware calls
getUser() → Supabase server burns the old refresh token, issues new access + refresh tokens
- Middleware sets new tokens in response cookies via
setAll()
- If the browser doesn't fully receive/process that response (interrupted navigation, rapid clicks, slow connection, tab backgrounded), the new cookies never land
- Browser still has the old (now-invalidated) refresh token
- Next request → middleware tries to refresh with the dead token → fails →
user = null → user appears signed out
Reproduction
This happens in production with real users, not just edge cases:
- Double-clicking a nav link — two rapid requests both hit middleware, first one burns the refresh token, second one gets the invalidated token
- Slow connections — page navigates away before the response with new cookies arrives
- Backgrounded tabs — browser throttles the response
- Any JS-triggered navigation that interrupts a pending middleware response
Environment
@supabase/ssr with Next.js 14 App Router
- Standard middleware pattern from Supabase docs (calling
getUser() on every request)
- Production deployment on Vercel
Current Workaround
We had to implement a workaround:
- Decode the JWT expiry in middleware before calling
getUser() — only refresh when the token is within 2 minutes of expiry instead of on every request
- Client-side recovery hook — if server-side session is lost, try
getUser() client-side as a fallback before declaring the user signed out
// Middleware workaround: only refresh when actually needed
function needsRefresh(request: NextRequest): boolean {
const tokenCookies = request.cookies.getAll()
.filter(c => c.name.includes('-auth-token'))
.sort((a, b) => a.name.localeCompare(b.name));
if (tokenCookies.length === 0) return true;
const raw = tokenCookies.map(c => c.value).join('');
try {
const parsed = JSON.parse(raw);
const accessToken = parsed.access_token || parsed[0];
// Decode JWT expiry without verification
const payload = JSON.parse(Buffer.from(accessToken.split('.')[1], 'base64url').toString());
const now = Math.floor(Date.now() / 1000);
return payload.exp - now < 120; // only refresh within 2 min of expiry
} catch {
return true;
}
}
Suggested Fix
The recommended middleware pattern should not call getUser() on every request. Either:
- Check JWT expiry first — only call
getUser() when the access token is actually near expiry (like our workaround above)
- Make refresh tokens reusable within a short window (e.g., 30 seconds) — so if the same refresh token is presented twice, it returns the same new tokens instead of failing
- Update the docs to warn about this race condition and recommend the expiry-check pattern
Option 2 would be the most robust fix since it eliminates the race condition at the source.
Impact
This causes silent session loss for users with no error message or explanation. They're just suddenly signed out and redirected to the login page. It's particularly bad on mobile (slower connections) and during active use (rapid navigation).
Bug Description
The recommended Supabase SSR middleware pattern calls
supabase.auth.getUser()on every request to refresh the auth token. Since Supabase refresh tokens are single-use, this creates a race condition that silently kills user sessions.The Problem
getUser()→ Supabase server burns the old refresh token, issues new access + refresh tokenssetAll()user = null→ user appears signed outReproduction
This happens in production with real users, not just edge cases:
Environment
@supabase/ssrwith Next.js 14 App RoutergetUser()on every request)Current Workaround
We had to implement a workaround:
getUser()— only refresh when the token is within 2 minutes of expiry instead of on every requestgetUser()client-side as a fallback before declaring the user signed outSuggested Fix
The recommended middleware pattern should not call
getUser()on every request. Either:getUser()when the access token is actually near expiry (like our workaround above)Option 2 would be the most robust fix since it eliminates the race condition at the source.
Impact
This causes silent session loss for users with no error message or explanation. They're just suddenly signed out and redirected to the login page. It's particularly bad on mobile (slower connections) and during active use (rapid navigation).