Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/three-ads-fold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Fix random sign-outs when the browser temporarily loses network connectivity.
124 changes: 124 additions & 0 deletions integration/tests/offline-session-persistence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { expect, test } from '@playwright/test';

import { appConfigs } from '../presets';
import type { FakeUser } from '../testUtils';
import { createTestUtils, testAgainstRunningApps } from '../testUtils';

testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })(
'offline session persistence @generic',
({ app }) => {
test.describe.configure({ mode: 'serial' });

let fakeUser: FakeUser;

test.beforeAll(async () => {
const u = createTestUtils({ app });
fakeUser = u.services.users.createFakeUser();
await u.services.users.createBapiUser(fakeUser);
});

test.afterAll(async () => {
await fakeUser.deleteIfExists();
await app.teardown();
});

test('user remains signed in after token endpoint outage and recovery', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });

await u.po.signIn.goTo();
await u.po.signIn.signInWithEmailAndInstantPassword({
email: fakeUser.email,
password: fakeUser.password,
});
await u.po.expect.toBeSignedIn();

const initialToken = await page.evaluate(() => window.Clerk?.session?.getToken());
expect(initialToken).toBeTruthy();

// Simulate token endpoint outage β€” requests will fail with network error
await page.route('**/v1/client/sessions/*/tokens**', route => route.abort('failed'));

// Clear token cache so any subsequent internal refresh hits the failing endpoint
await page.evaluate(() => window.Clerk?.session?.clearCache());

// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(3_000);

// Restore network
await page.unrouteAll();

// The session cookie must NOT have been removed during the outage.
// Before the fix, empty tokens would be dispatched to AuthCookieService,
// which interpreted them as sign-out and removed the __session cookie.
await u.po.expect.toBeSignedIn();

// Verify recovery: a fresh token can still be obtained
const recoveredToken = await page.evaluate(() => window.Clerk?.session?.getToken());
expect(recoveredToken).toBeTruthy();
});

test('session survives page reload after token endpoint outage', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });

await u.po.signIn.goTo();
await u.po.signIn.signInWithEmailAndInstantPassword({
email: fakeUser.email,
password: fakeUser.password,
});
await u.po.expect.toBeSignedIn();

// Fail all token refresh requests
await page.route('**/v1/client/sessions/*/tokens**', route => route.abort('failed'));

// Force a refresh attempt that will fail
await page.evaluate(() => window.Clerk?.session?.clearCache());

// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(2_000);

// Restore network before reload
await page.unrouteAll();

// Reload the page β€” if the __session cookie was removed during the outage,
// the server would treat this as an unauthenticated request
await page.reload();
await u.po.clerk.toBeLoaded();

await u.po.expect.toBeSignedIn();
});

test('session cookie persists when browser goes fully offline and recovers', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });

await u.po.signIn.goTo();
await u.po.signIn.signInWithEmailAndInstantPassword({
email: fakeUser.email,
password: fakeUser.password,
});
await u.po.expect.toBeSignedIn();

// Go fully offline β€” sets navigator.onLine to false,
// which triggers the isBrowserOnline() guard in _getToken
await context.setOffline(true);

// Clear token cache while offline
await page.evaluate(() => window.Clerk?.session?.clearCache());

// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(2_000);

// Come back online
await context.setOffline(false);

// Reload β€” session cookie must still be intact
await page.reload();
await u.po.clerk.toBeLoaded();

await u.po.expect.toBeSignedIn();

// Confirm a fresh token can be obtained after recovery
const token = await page.evaluate(() => window.Clerk?.session?.getToken());
expect(token).toBeTruthy();
});
},
);
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"files": [
{ "path": "./dist/clerk.js", "maxSize": "539KB" },
{ "path": "./dist/clerk.js", "maxSize": "540KB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "66KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "108KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "307KB" },
Expand Down
45 changes: 38 additions & 7 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { createCheckAuthorization } from '@clerk/shared/authorization';
import { isValidBrowserOnline } from '@clerk/shared/browser';
import { isBrowserOnline, isValidBrowserOnline } from '@clerk/shared/browser';
import {
ClerkOfflineError,
ClerkRuntimeError,
ClerkWebAuthnError,
is4xxError,
is429Error,
Expand Down Expand Up @@ -445,18 +446,31 @@ export class Session extends BaseResource implements SessionResource {
// Dispatch tokenUpdate only for __session tokens with the session's active organization ID, and not JWT templates
const shouldDispatchTokenUpdate = !template && organizationId === this.lastActiveOrganizationId;

let result: string | null;

if (cacheResult) {
// Proactive refresh is handled by timers scheduled in the cache
// Prefer synchronous read to avoid microtask overhead when token is already resolved
const cachedToken = cacheResult.entry.resolvedToken ?? (await cacheResult.entry.tokenResolver);
if (shouldDispatchTokenUpdate) {
// Only emit token updates when we have an actual token β€” emitting with an empty
// token causes AuthCookieService to remove the __session cookie (looks like sign-out).
if (shouldDispatchTokenUpdate && cachedToken.getRawString()) {
eventBus.emit(events.TokenUpdate, { token: cachedToken });
}
// Return null when raw string is empty to indicate signed-out state
return cachedToken.getRawString() || null;
result = cachedToken.getRawString() || null;
} else if (!isBrowserOnline()) {
throw new ClerkRuntimeError('Browser is offline, skipping token fetch', { code: 'network_error' });
} else {
result = await this.#fetchToken(template, organizationId, tokenId, shouldDispatchTokenUpdate, skipCache);
}

// Throw when offline and no token so retry() in getToken() can fire.
// Without this, _getToken returns null (success) and retry() never calls shouldRetry.
if (result === null && !isValidBrowserOnline()) {
throw new ClerkRuntimeError('Network request failed while offline', { code: 'network_error' });
}

return this.#fetchToken(template, organizationId, tokenId, shouldDispatchTokenUpdate, skipCache);
return result;
}

#createTokenResolver(
Expand Down Expand Up @@ -484,6 +498,12 @@ export class Session extends BaseResource implements SessionResource {
return;
}

// Never dispatch empty tokens β€” this would cause AuthCookieService to remove
// the __session cookie even though the user is still authenticated.
if (!token.getRawString()) {
return;
}

eventBus.emit(events.TokenUpdate, { token });

if (token.jwt) {
Expand All @@ -509,9 +529,14 @@ export class Session extends BaseResource implements SessionResource {
});

return tokenResolver.then(token => {
const rawString = token.getRawString();
if (!rawString) {
// Throw so retry logic in getToken() can handle it,
// rather than silently returning null (which callers interpret as "signed out").
throw new ClerkRuntimeError('Token fetch returned empty response', { code: 'network_error' });
}
this.#dispatchTokenEvents(token, shouldDispatchTokenUpdate);
// Return null when raw string is empty to indicate signed-out state
return token.getRawString() || null;
return rawString;
});
}

Expand Down Expand Up @@ -541,6 +566,12 @@ export class Session extends BaseResource implements SessionResource {
// This allows concurrent calls to continue using the stale token
tokenResolver
.then(token => {
// Never cache or dispatch empty tokens β€” preserve the stale-but-valid
// token in cache instead of replacing it with an empty one.
if (!token.getRawString()) {
return;
}

// Cache the resolved token for future calls
// Re-register onRefresh to handle the next refresh cycle when this token approaches expiration
SessionTokenCache.set({
Expand Down
Loading
Loading