Skip to content

Single-use refresh tokens + middleware refresh on every request = silent session loss #190

Description

@joerizz23

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

  1. Browser has cookies with access token (expired or near-expiry) + refresh token
  2. Middleware calls getUser() → Supabase server burns the old refresh token, issues new access + refresh tokens
  3. Middleware sets new tokens in response cookies via setAll()
  4. If the browser doesn't fully receive/process that response (interrupted navigation, rapid clicks, slow connection, tab backgrounded), the new cookies never land
  5. Browser still has the old (now-invalidated) refresh token
  6. 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:

  1. 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
  2. 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:

  1. Check JWT expiry first — only call getUser() when the access token is actually near expiry (like our workaround above)
  2. 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
  3. 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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions