-
Notifications
You must be signed in to change notification settings - Fork 611
Description
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
-
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. -
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.
-
There is no recovery path. Once
_removeSession()fires, the session is gone from storage.getSession()returns{ session: null, error: null }— theerror: nullmeans callers have no way to distinguish "user is not logged in" from "SDK destroyed your valid session". -
_removeSession()firesSIGNED_OUTevent. This is misleading — the user never signed out. Listeners that clean up state onSIGNED_OUTwill 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:
- Current session lost when auth function call fails auth-js#904 — "Current session lost when auth function call fails" (closed by fix: don't call removeSession prematurely auth-js#915, but that PR only fixed sign-in/sign-up flows, NOT
_callRefreshToken) - Network error removes session data auth-js#141 — "Network error removes session data" (closed, partially fixed — retryable errors now preserved, but non-retryable auth errors still nuke session)
- [Bug] iOS Safari/Chrome: supabase.auth.getSession() intermittently returns null until app is backgrounded/foregrounded #1560 — "iOS Safari/Chrome: getSession() intermittently returns null" (still open — likely same root cause)
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)