Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/fix_background_refresh_session_nuke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@clerk/clerk-js': patch
---

fix(clerk-js): Prevent background token refresh from destroying sessions on mobile

On iOS, background thread throttling can starve the JS event loop for hours (e.g., overnight audio apps). When the SDK's background refresh timer eventually fires with stale credentials, the resulting 401 would trigger `handleUnauthenticated()` and destroy the session even though it's still valid on the server.

Adds an early return in `#refreshTokenInBackground()`, gated to headless/mobile runtimes only (Expo sets `runtimeEnvironment` to `'headless'`). If the token has already expired when the refresh timer fires, bail out instead of sending a request with stale credentials. The next foreground `getToken()` call handles token acquisition through the normal path with proper retry logic.
14 changes: 14 additions & 0 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,20 @@ export class Session extends BaseResource implements SessionResource {

Session.#backgroundRefreshInProgress.add(tokenId);

// Mobile only: skip this refresh if the token is already expired.
// On iOS, the OS throttles background JS threads for hours (e.g. overnight audio apps).
// The refresh timer fires late — well past token expiry — with stale credentials.
// If we send that request, the 401 response triggers handleUnauthenticated(), which
// destroys the session even though it's still valid on the server (30-day lifetime).
// Instead, bail out here and let the next foreground getToken() call recover normally.
const experimental = Session.clerk?.__internal_getOption?.('experimental');
const isHeadless = experimental?.runtimeEnvironment === 'headless';
const lastTokenExp = this.lastActiveToken?.jwt?.claims?.exp;
if (isHeadless && lastTokenExp && Date.now() / 1000 > lastTokenExp) {
Session.#backgroundRefreshInProgress.delete(tokenId);
return;
}

const tokenResolver = this.#createTokenResolver(template, organizationId, false);

// Don't cache the promise immediately - only update cache on success
Expand Down
43 changes: 43 additions & 0 deletions packages/clerk-js/src/core/resources/__tests__/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,49 @@ describe('Session', () => {
expect(token).toEqual(mockJwt);
});

it('skips background refresh when token is expired on headless runtime', async () => {
BaseResource.clerk = clerkMock({
// Simulate Expo/React Native headless runtime
__internal_getOption: vi.fn().mockImplementation((key: string) => {
if (key === 'experimental') {
return { runtimeEnvironment: 'headless' };
}
return undefined;
}),
});
const requestSpy = BaseResource.clerk.getFapiClient().request as Mock<any>;

const _session = new Session({
status: 'active',
id: 'session_1',
object: 'session',
user: createUser({}),
last_active_organization_id: null,
last_active_token: { object: 'token', jwt: mockJwt },
actor: null,
created_at: new Date().getTime(),
updated_at: new Date().getTime(),
} as SessionJSON);

// Let the initial cache populate from lastActiveToken
await Promise.resolve();
requestSpy.mockClear();

// Simulate iOS background throttling: jump the system clock well past
// token expiration WITHOUT firing timers. This is what happens when iOS
// starves the JS thread — the scheduled timer doesn't fire on time.
// mockJwt has iat=1666648250, exp=1666648310 (60s token)
vi.setSystemTime(new Date(1666648400 * 1000)); // 150s after iat, 90s past exp

// Now fire the pending refresh timer. It was scheduled for ~43s but
// fires late (simulating iOS throttling). Date.now() is past exp,
// so the early return should prevent the API call.
await vi.advanceTimersByTimeAsync(44 * 1000);

// No API call should have been made — the early return bailed out
expect(requestSpy).not.toHaveBeenCalled();
});

it('does not make API call when token has plenty of time remaining', async () => {
BaseResource.clerk = clerkMock();
const requestSpy = BaseResource.clerk.getFapiClient().request as Mock<any>;
Expand Down
Loading