Skip to content

Commit cded2de

Browse files
committed
feat(supabase): add failOnNetworkError option to prevent silent fallback to anon key
1 parent af85057 commit cded2de

File tree

3 files changed

+82
-1
lines changed

3 files changed

+82
-1
lines changed

packages/core/supabase-js/src/SupabaseClient.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export default class SupabaseClient<
8888
protected accessToken?: () => Promise<string | null>
8989

9090
protected headers: Record<string, string>
91+
protected failOnNetworkError: boolean
9192

9293
/**
9394
* Create a new client for use in the browser.
@@ -136,6 +137,7 @@ export default class SupabaseClient<
136137

137138
this.storageKey = settings.auth.storageKey ?? ''
138139
this.headers = settings.global.headers ?? {}
140+
this.failOnNetworkError = settings.auth.failOnNetworkError ?? false
139141

140142
if (!settings.accessToken) {
141143
this.auth = this._initSupabaseAuthClient(
@@ -338,7 +340,14 @@ export default class SupabaseClient<
338340
return await this.accessToken()
339341
}
340342

341-
const { data } = await this.auth.getSession()
343+
const { data, error } = await this.auth.getSession()
344+
345+
// If failOnNetworkError is enabled and there's a retryable error (network failure),
346+
// throw instead of silently falling back to anon key.
347+
// status === 0 indicates a network error (AuthRetryableFetchError)
348+
if (this.failOnNetworkError && error && error.status === 0) {
349+
throw error
350+
}
342351

343352
return data.session?.access_token ?? this.supabaseKey
344353
}

packages/core/supabase-js/src/lib/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,16 @@ export type SupabaseClientOptions<SchemaName> = {
8181
* throwing the error instead of returning it as part of a successful response.
8282
*/
8383
throwOnError?: SupabaseAuthClientOptions['throwOnError']
84+
/**
85+
* When true, throws an error if session retrieval fails due to network issues
86+
* instead of silently falling back to the anonymous API key.
87+
*
88+
* This prevents RLS policies from silently failing when `auth.uid()` returns null
89+
* due to network errors during token refresh.
90+
*
91+
* @default false
92+
*/
93+
failOnNetworkError?: boolean
8494
}
8595
/**
8696
* Options passed to the realtime-js instance

packages/core/supabase-js/test/unit/SupabaseClient.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,68 @@ describe('SupabaseClient', () => {
256256
const token = await client._getAccessToken()
257257
expect(token).toBe(KEY)
258258
})
259+
260+
test('should fallback to supabaseKey on network error by default', async () => {
261+
const client = createClient(URL, KEY)
262+
263+
// Mock network error (status 0 indicates AuthRetryableFetchError)
264+
const networkError = { message: 'Network error', status: 0 }
265+
client.auth.getSession = jest.fn().mockResolvedValue({
266+
data: { session: null },
267+
error: networkError,
268+
})
269+
270+
// @ts-ignore - accessing private method
271+
const token = await client._getAccessToken()
272+
// Should fallback to KEY, not throw
273+
expect(token).toBe(KEY)
274+
})
275+
276+
test('should throw on network error when failOnNetworkError is true', async () => {
277+
const client = createClient(URL, KEY, {
278+
auth: { failOnNetworkError: true },
279+
})
280+
281+
// Mock network error (status 0 indicates AuthRetryableFetchError)
282+
const networkError = { message: 'Network error', status: 0 }
283+
client.auth.getSession = jest.fn().mockResolvedValue({
284+
data: { session: null },
285+
error: networkError,
286+
})
287+
288+
// @ts-ignore - accessing private method
289+
await expect(client._getAccessToken()).rejects.toEqual(networkError)
290+
})
291+
292+
test('should not throw on non-network auth errors even when failOnNetworkError is true', async () => {
293+
const client = createClient(URL, KEY, {
294+
auth: { failOnNetworkError: true },
295+
})
296+
297+
// Mock non-network auth error (e.g., session expired - status 401)
298+
const authError = { message: 'Session expired', status: 401 }
299+
client.auth.getSession = jest.fn().mockResolvedValue({
300+
data: { session: null },
301+
error: authError,
302+
})
303+
304+
// @ts-ignore - accessing private method
305+
const token = await client._getAccessToken()
306+
// Should fallback to KEY, not throw (status !== 0)
307+
expect(token).toBe(KEY)
308+
})
309+
310+
test('should store failOnNetworkError setting', () => {
311+
const clientWithOption = createClient(URL, KEY, {
312+
auth: { failOnNetworkError: true },
313+
})
314+
// @ts-ignore - accessing protected property
315+
expect(clientWithOption.failOnNetworkError).toBe(true)
316+
317+
const clientWithoutOption = createClient(URL, KEY)
318+
// @ts-ignore - accessing protected property
319+
expect(clientWithoutOption.failOnNetworkError).toBe(false)
320+
})
259321
})
260322

261323
describe('Realtime Authentication', () => {

0 commit comments

Comments
 (0)