Skip to content

Bug: _callRefreshToken permanently deletes session on non-retryable refresh failure, even when access token is still valid #2145

@haveaguess

Description

@haveaguess

Bug: _callRefreshToken permanently deletes session on non-retryable refresh failure, even when access token is still valid

Bug report

  • I confirm this is a bug with Supabase, not with my own application.
  • I confirm I have searched the Docs, Discussions, and Discord before opening this.

Describe the bug

When getSession() proactively refreshes a token that's within the EXPIRY_MARGIN_MS (90s) window, and the refresh fails with a non-retryable error (e.g. 400 invalid_grant), _callRefreshToken calls _removeSession() which permanently deletes the session from storage — even though the access token is still valid for up to 90 more seconds.

After deletion, every subsequent getSession() call returns { session: null, error: null } forever. The user is silently logged out with no error reported to the caller.

The problematic code

GoTrueClient.ts_callRefreshToken method (still present in v2.98.0, line ~1997 in compiled output):

catch (error) {
    if (isAuthError(error)) {
        const result = { data: null, error };
        if (!isAuthRetryableFetchError(error)) {
            await this._removeSession();  // 💀 Permanently deletes session from storage
        }
        this.refreshingDeferred?.resolve(result);
        return result;
    }
}

The refresh is triggered proactively by getSession()__loadSession():

const hasExpired = currentSession.expires_at
    ? currentSession.expires_at * 1000 - Date.now() < EXPIRY_MARGIN_MS  // 90,000ms
    : false;

if (hasExpired) {
    // Token still valid for up to 90s, but SDK considers it "expired"
    const { data: session, error } = await this._callRefreshToken(currentSession.refresh_token);
    // If this fails → session PERMANENTLY DELETED
}

Why this is wrong

  1. The access token is still valid. The EXPIRY_MARGIN_MS (90s) is a proactive optimization — the token hasn't actually expired. Destroying a valid token because an optimization failed is incorrect.

  2. The refresh failure is often transient. Common causes include refresh token rotation races (two tabs refreshing simultaneously), brief Supabase service issues returning 400 instead of 5xx, and mobile browser tab lifecycle quirks.

  3. There is no recovery path. Once _removeSession() fires, the session is gone from storage. getSession() returns { session: null, error: null } — the error: null means callers have no way to distinguish "user is not logged in" from "SDK destroyed your valid session".

  4. _removeSession() fires SIGNED_OUT event. This is misleading — the user never signed out. Listeners that clean up state on SIGNED_OUT will tear down the application unnecessarily.

Reproduction

Automated test (runs against real GoTrueClient)

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { GoTrueClient } from "@supabase/auth-js";

describe("getSession permanently null after failed refresh", () => {
  let authClient: GoTrueClient;
  let memoryStore: Record<string, string>;

  const createExpiringSession = (secondsUntilExpiry: number) => ({
    access_token: "still-valid-access-token",
    refresh_token: "refresh-token-abc123",
    expires_in: secondsUntilExpiry,
    expires_at: Math.floor(Date.now() / 1000) + secondsUntilExpiry,
    token_type: "bearer",
    user: {
      id: "user-123",
      email: "test@example.com",
      aud: "authenticated",
      role: "authenticated",
      app_metadata: {},
      user_metadata: {},
      created_at: new Date().toISOString(),
    },
  });

  beforeEach(() => {
    vi.useFakeTimers({ shouldAdvanceTime: false });
    memoryStore = {};

    // Mock fetch: returns 400 for refresh (simulates token rotation race)
    const mockFetch = vi.fn().mockImplementation(async (url: string) => {
      if (url.includes("/token?grant_type=refresh_token")) {
        return {
          ok: false,
          status: 400,
          json: async () => ({
            error: "invalid_grant",
            error_description: "Invalid Refresh Token: Already Used",
          }),
          headers: new Headers({ "Content-Type": "application/json" }),
        };
      }
      return { ok: true, status: 200, json: async () => ({}), headers: new Headers() };
    });

    authClient = new GoTrueClient({
      url: "https://fake.supabase.co/auth/v1",
      headers: { apikey: "fake-anon-key" },
      storageKey: "sb-test-auth-token",
      autoRefreshToken: false,
      persistSession: true,
      detectSessionInUrl: false,
      storage: {
        getItem: (key: string) => memoryStore[key] ?? null,
        setItem: (key: string, value: string) => { memoryStore[key] = value; },
        removeItem: (key: string) => { delete memoryStore[key]; },
      },
      fetch: mockFetch,
    });
  });

  afterEach(() => vi.useRealTimers());

  it("BUG: session within 90s margin → refresh 400 → session PERMANENTLY deleted", async () => {
    // Token expires in 60s — within the 90s EXPIRY_MARGIN but STILL VALID
    const session = createExpiringSession(60);
    memoryStore["sb-test-auth-token"] = JSON.stringify(session);

    expect(memoryStore["sb-test-auth-token"]).toBeDefined(); // Session IS in storage

    const result1 = await authClient.getSession();

    // SDK tried to refresh, got 400, called _removeSession()
    expect(result1.data.session).toBeNull();
    expect(memoryStore["sb-test-auth-token"]).toBeUndefined(); // 💀 DELETED from storage

    // All subsequent calls return null forever — no recovery
    const result2 = await authClient.getSession();
    expect(result2.data.session).toBeNull();
    expect(result2.error).toBeNull(); // No error reported — silent failure
  });
});

Browser console reproduction (against a real Supabase instance)

// Run in browser console with any Supabase project
const { createClient } = await import("https://esm.sh/@supabase/supabase-js@2");

const SUPABASE_URL = "https://YOUR_PROJECT.supabase.co";
const ANON_KEY = "YOUR_ANON_KEY";
const REPRO_KEY = "sb-repro-bug-auth-token";

// 1. Inject a session within the 90s danger zone
const nowSec = Math.floor(Date.now() / 1000);
localStorage.setItem(REPRO_KEY, JSON.stringify({
  access_token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.fake",
  refresh_token: "fake-refresh-token-will-get-400",
  expires_in: 60,
  expires_at: nowSec + 60,  // 60s remaining — within 90s EXPIRY_MARGIN
  token_type: "bearer",
  user: { id: "x", email: "x@x.com", aud: "authenticated", role: "authenticated",
          app_metadata: {}, user_metadata: {}, created_at: new Date().toISOString() }
}));

// 2. Wrap storage to trace operations
const trace = [];
const tracingStorage = {
  getItem: (key) => { const v = localStorage.getItem(key); trace.push({ op: "GET", key, found: v !== null }); return v; },
  setItem: (key, value) => { trace.push({ op: "SET", key }); localStorage.setItem(key, value); },
  removeItem: (key) => { trace.push({ op: "DELETE", key }); localStorage.removeItem(key); },
};

// 3. Create client — initialization triggers _recoverAndRefresh → _callRefreshToken → _removeSession
const supabase = createClient(SUPABASE_URL, ANON_KEY, {
  auth: { autoRefreshToken: false, persistSession: true, detectSessionInUrl: false,
          storageKey: REPRO_KEY, storage: tracingStorage }
});

await new Promise(r => setTimeout(r, 3000));

// 4. Observe the damage
console.table(trace);
// Output:
//   GET  sb-repro-bug-auth-token-code-verifier  → not found
//   GET  sb-repro-bug-auth-token                → found ✅
//   GET  sb-repro-bug-auth-token                → found ✅
//   DELETE sb-repro-bug-auth-token              → 💀 DELETED
//   GET  sb-repro-bug-auth-token                → not found
//   GET  sb-repro-bug-auth-token                → not found

const result = await supabase.auth.getSession();
console.log("session:", result.data.session);  // null
console.log("error:", result.error);            // null — SILENT FAILURE

localStorage.removeItem(REPRO_KEY);

Verified live

We ran this reproduction against a real Supabase instance (sfkvqohjfyohvhgsgybk.supabase.co) with auth-js v2.88.0 and confirmed the storage trace shows the exact sequence: GET (found) → GET (found) → DELETE → GET (not found).

We also confirmed the bug is still present in v2.98.0 — the _removeSession() call inside _callRefreshToken's catch block for non-retryable errors remains at line 1997 of the compiled output.

Real-world impact

We're seeing this in production affecting real users:

  • 19 occurrences, 5 users reported via Sentry
  • Primarily affects Mobile Safari (iOS 18.7) and Chrome on Windows/iOS
  • Manifests as HTTP 401 errors during long-running operations (SSE streams, long http calls) that start during the ~59 minute mark of a session
  • Users have no way to recover without a full page reload and re-login

Suggested fix

_callRefreshToken should not call _removeSession() when the access token is still within its valid expiry window. A proactive refresh failure should degrade gracefully — return the existing (still-valid) session, not destroy it.

// Instead of unconditionally deleting:
if (!isAuthRetryableFetchError(error)) {
    await this._removeSession();
}

// Consider: only remove if the token has ACTUALLY expired
if (!isAuthRetryableFetchError(error)) {
    const currentSession = await this._getSessionFromStorage();
    const actuallyExpired = currentSession?.expires_at
        ? currentSession.expires_at * 1000 < Date.now()
        : true;
    if (actuallyExpired) {
        await this._removeSession();
    } else {
        // Token still valid — keep it, log the refresh failure, try again later
        this._debug('_callRefreshToken', 'refresh failed but token still valid, keeping session');
    }
}

Alternatively, getSession() could simply return the existing session when the proactive refresh fails, since the token hasn't actually expired yet.

Related issues

Note: auth-js has moved into this monorepo at packages/core/auth-js/. The old issues are archived at supabase/auth-js:

Environment

  • auth-js version: 2.88.0 (verified still present in 2.98.0)
  • Browser: Mobile Safari 26.2 (iOS 18.7), Chrome 144 (Windows), Chrome Mobile iOS 145
  • Framework: React (Vite)

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