Skip to content
5 changes: 5 additions & 0 deletions .changeset/fix-token-refresh-race-condition.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/clerk-js": patch
---

Fix race condition where multiple browser tabs could fetch session tokens simultaneously. The `refreshTokenOnFocus` handler now uses the same cross-tab lock as the session token poller, preventing duplicate API calls when switching between tabs or when focus events fire while another tab is already refreshing the token.
35 changes: 25 additions & 10 deletions packages/clerk-js/src/core/auth/AuthCookieService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ import { createSessionCookie } from './cookies/session';
import { getCookieSuffix } from './cookieSuffix';
import type { DevBrowser } from './devBrowser';
import { createDevBrowser } from './devBrowser';
import { SessionCookiePoller } from './SessionCookiePoller';
import type { SafeLockReturn } from './safeLock';
import { SafeLock } from './safeLock';
import { REFRESH_SESSION_TOKEN_LOCK_KEY, SessionCookiePoller } from './SessionCookiePoller';

// TODO(@dimkl): make AuthCookieService singleton since it handles updating cookies using a poller
// and we need to avoid updating them concurrently.
Expand All @@ -41,11 +43,15 @@ import { SessionCookiePoller } from './SessionCookiePoller';
* - handleUnauthenticatedDevBrowser(): resets dev browser in case of invalid dev browser
*/
export class AuthCookieService {
private poller: SessionCookiePoller | null = null;
private clientUat: ClientUatCookieHandler;
private sessionCookie: SessionCookieHandler;
private activeCookie: ReturnType<typeof createCookieHandler>;
private clientUat: ClientUatCookieHandler;
private devBrowser: DevBrowser;
private poller: SessionCookiePoller | null = null;
private sessionCookie: SessionCookieHandler;
/**
* Shared lock for coordinating token refresh operations across tabs
*/
private tokenRefreshLock: SafeLockReturn;

public static async create(
clerk: Clerk,
Expand All @@ -66,6 +72,11 @@ export class AuthCookieService {
private instanceType: InstanceType,
private clerkEventBus: ReturnType<typeof createClerkEventBus>,
) {
// Create shared lock for cross-tab token refresh coordination.
// This lock is used by both the poller and the focus handler to prevent
// concurrent token fetches across tabs.
this.tokenRefreshLock = SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY);

// set cookie on token update
eventBus.on(events.TokenUpdate, ({ token }) => {
this.updateSessionCookie(token && token.getRawString());
Expand All @@ -77,14 +88,14 @@ export class AuthCookieService {
this.refreshTokenOnFocus();
this.startPollingForToken();

this.clientUat = createClientUatCookie(cookieSuffix);
this.sessionCookie = createSessionCookie(cookieSuffix);
this.activeCookie = createActiveContextCookie();
this.clientUat = createClientUatCookie(cookieSuffix);
this.devBrowser = createDevBrowser({
frontendApi: clerk.frontendApi,
fapiClient,
cookieSuffix,
fapiClient,
frontendApi: clerk.frontendApi,
});
this.sessionCookie = createSessionCookie(cookieSuffix);
}

public async setup() {
Expand Down Expand Up @@ -126,7 +137,7 @@ export class AuthCookieService {

public startPollingForToken() {
if (!this.poller) {
this.poller = new SessionCookiePoller();
this.poller = new SessionCookiePoller(this.tokenRefreshLock);
this.poller.startPollingForSessionToken(() => this.refreshSessionToken());
}
}
Expand All @@ -147,7 +158,11 @@ export class AuthCookieService {
// is updated as part of the scheduled microtask. Our existing event-based mechanism to update the cookie schedules a task, and so the cookie
// is updated too late and not guaranteed to be fresh before the refetch occurs.
// While online `.schedule()` executes synchronously and immediately, ensuring the above mechanism will not break.
void this.refreshSessionToken({ updateCookieImmediately: true });
//
// We use the shared lock to coordinate with the poller and other tabs, preventing
// concurrent token fetches when multiple tabs become visible or when focus events
// fire while the poller is already refreshing the token.
void this.tokenRefreshLock.acquireLockAndRun(() => this.refreshSessionToken({ updateCookieImmediately: true }));
}
});
}
Expand Down
27 changes: 25 additions & 2 deletions packages/clerk-js/src/core/auth/SessionCookiePoller.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,40 @@
import { createWorkerTimers } from '@clerk/shared/workerTimers';

import type { SafeLockReturn } from './safeLock';
import { SafeLock } from './safeLock';

const REFRESH_SESSION_TOKEN_LOCK_KEY = 'clerk.lock.refreshSessionToken';
export const REFRESH_SESSION_TOKEN_LOCK_KEY = 'clerk.lock.refreshSessionToken';
const INTERVAL_IN_MS = 5 * 1_000;

/**
* Polls for session token refresh at regular intervals with cross-tab coordination.
*
* @example
* ```typescript
* // Create a shared lock for coordination with focus handlers
* const sharedLock = SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY);
*
* // Poller uses the shared lock
* const poller = new SessionCookiePoller(sharedLock);
* poller.startPollingForSessionToken(() => refreshToken());
*
* // Focus handler can use the same lock to prevent races
* window.addEventListener('focus', () => {
* sharedLock.acquireLockAndRun(() => refreshToken());
* });
* ```
*/
export class SessionCookiePoller {
private lock = SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY);
private lock: SafeLockReturn;
private workerTimers = createWorkerTimers();
private timerId: ReturnType<typeof this.workerTimers.setInterval> | null = null;
// Disallows for multiple `startPollingForSessionToken()` calls before `callback` is executed.
private initiated = false;

constructor(lock?: SafeLockReturn) {
this.lock = lock ?? SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY);
}

public startPollingForSessionToken(cb: () => Promise<unknown>): void {
if (this.timerId || this.initiated) {
return;
Expand Down
146 changes: 146 additions & 0 deletions packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import type { SafeLockReturn } from '../safeLock';
import { SessionCookiePoller } from '../SessionCookiePoller';

describe('SessionCookiePoller', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

describe('shared lock coordination', () => {
it('accepts an external lock for coordination with other components', () => {
const sharedLock: SafeLockReturn = {
acquireLockAndRun: vi.fn().mockResolvedValue(undefined),
};

const poller = new SessionCookiePoller(sharedLock);
const callback = vi.fn().mockResolvedValue(undefined);

poller.startPollingForSessionToken(callback);

// Verify the shared lock is used
expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(callback);

poller.stopPollingForSessionToken();
});

it('creates internal lock when none provided (backward compatible)', () => {
// Should not throw when no lock is provided
const poller = new SessionCookiePoller();
expect(poller).toBeInstanceOf(SessionCookiePoller);
});

it('enables focus handler and poller to share the same lock', () => {
// This test demonstrates the shared lock pattern used in AuthCookieService
const sharedLock: SafeLockReturn = {
acquireLockAndRun: vi.fn().mockImplementation(async (cb: () => Promise<unknown>) => {
return cb();
}),
};

const poller = new SessionCookiePoller(sharedLock);
const pollerCallback = vi.fn().mockResolvedValue('poller-result');

// Poller uses the shared lock
poller.startPollingForSessionToken(pollerCallback);

// Simulate focus handler also using the shared lock (like AuthCookieService does)
const focusCallback = vi.fn().mockResolvedValue('focus-result');
void sharedLock.acquireLockAndRun(focusCallback);

// Both should use the same lock instance
expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(2);
expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(pollerCallback);
expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(focusCallback);

poller.stopPollingForSessionToken();
});
});

describe('startPollingForSessionToken', () => {
it('executes callback immediately on start', () => {
const sharedLock: SafeLockReturn = {
acquireLockAndRun: vi.fn().mockResolvedValue(undefined),
};

const poller = new SessionCookiePoller(sharedLock);
const callback = vi.fn().mockResolvedValue(undefined);

poller.startPollingForSessionToken(callback);

expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(callback);

poller.stopPollingForSessionToken();
});

it('prevents multiple concurrent polling sessions', () => {
const sharedLock: SafeLockReturn = {
acquireLockAndRun: vi.fn().mockResolvedValue(undefined),
};

const poller = new SessionCookiePoller(sharedLock);
const callback = vi.fn().mockResolvedValue(undefined);

poller.startPollingForSessionToken(callback);
poller.startPollingForSessionToken(callback); // Second call should be ignored

expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(1);

poller.stopPollingForSessionToken();
});
});

describe('stopPollingForSessionToken', () => {
it('allows restart after stop', async () => {
const sharedLock: SafeLockReturn = {
acquireLockAndRun: vi.fn().mockResolvedValue(undefined),
};

const poller = new SessionCookiePoller(sharedLock);
const callback = vi.fn().mockResolvedValue(undefined);

// Start and stop
poller.startPollingForSessionToken(callback);
poller.stopPollingForSessionToken();

// Clear mock to check restart
vi.mocked(sharedLock.acquireLockAndRun).mockClear();

// Should be able to start again
poller.startPollingForSessionToken(callback);
expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(1);

poller.stopPollingForSessionToken();
});
});

describe('polling interval', () => {
it('schedules next poll after callback completes', async () => {
const sharedLock: SafeLockReturn = {
acquireLockAndRun: vi.fn().mockResolvedValue(undefined),
};

const poller = new SessionCookiePoller(sharedLock);
const callback = vi.fn().mockResolvedValue(undefined);

poller.startPollingForSessionToken(callback);

// Initial call
expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(1);

// Wait for first interval (5 seconds)
await vi.advanceTimersByTimeAsync(5000);

// Should have scheduled another call
expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(2);

poller.stopPollingForSessionToken();
});
});
});
106 changes: 106 additions & 0 deletions packages/clerk-js/src/core/auth/__tests__/safeLock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, expect, it, vi } from 'vitest';

import type { SafeLockReturn } from '../safeLock';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This type doesn't exist in the referenced file

import { SafeLock } from '../safeLock';

describe('SafeLock', () => {
describe('interface contract', () => {
it('returns SafeLockReturn interface with acquireLockAndRun method', () => {
const lock = SafeLock('test-interface');

expect(lock).toHaveProperty('acquireLockAndRun');
expect(typeof lock.acquireLockAndRun).toBe('function');
});

it('SafeLockReturn type allows creating mock implementations', () => {
// This test verifies the type interface works correctly for mocking
const mockLock: SafeLockReturn = {
acquireLockAndRun: vi.fn().mockResolvedValue('mock-result'),
};

expect(mockLock.acquireLockAndRun).toBeDefined();
});
});

describe('Web Locks API path', () => {
it('uses Web Locks API when available in secure context', async () => {
// Skip if Web Locks not available (like in jsdom without polyfill)
if (!('locks' in navigator) || !navigator.locks) {
return;
}

const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout');
const lock = SafeLock('test-weblocks-' + Date.now());
const callback = vi.fn().mockResolvedValue('web-locks-result');

const result = await lock.acquireLockAndRun(callback);

expect(callback).toHaveBeenCalled();
expect(result).toBe('web-locks-result');
// Verify cleanup happened
expect(clearTimeoutSpy).toHaveBeenCalled();

clearTimeoutSpy.mockRestore();
});
});

describe('shared lock pattern', () => {
it('allows multiple components to share a lock via SafeLockReturn interface', async () => {
// This demonstrates how AuthCookieService shares a lock between poller and focus handler
const executionLog: string[] = [];

const sharedLock: SafeLockReturn = {
acquireLockAndRun: vi.fn().mockImplementation(async (cb: () => Promise<unknown>) => {
executionLog.push('lock-acquired');
const result = await cb();
executionLog.push('lock-released');
return result;
}),
};

// Simulate poller using the lock
await sharedLock.acquireLockAndRun(() => {
executionLog.push('poller-callback');
return Promise.resolve('poller-done');
});

// Simulate focus handler using the same lock
await sharedLock.acquireLockAndRun(() => {
executionLog.push('focus-callback');
return Promise.resolve('focus-done');
});

expect(executionLog).toEqual([
'lock-acquired',
'poller-callback',
'lock-released',
'lock-acquired',
'focus-callback',
'lock-released',
]);
});

it('mock lock can simulate sequential execution', async () => {
const results: string[] = [];

// Create a mock that simulates sequential lock behavior
const sharedLock: SafeLockReturn = {
acquireLockAndRun: vi.fn().mockImplementation(async (cb: () => Promise<unknown>) => {
const result = await cb();
results.push(result as string);
return result;
}),
};

// Both "tabs" try to refresh
const promise1 = sharedLock.acquireLockAndRun(() => Promise.resolve('tab1-result'));
const promise2 = sharedLock.acquireLockAndRun(() => Promise.resolve('tab2-result'));

await Promise.all([promise1, promise2]);

expect(results).toContain('tab1-result');
expect(results).toContain('tab2-result');
expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(2);
});
});
});
Loading
Loading