From d639ab4d0d2d2f5459d5fea863d8bfda6cf4ee36 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 23 Oct 2026 14:01:33 -0700 Subject: [PATCH 1/5] test(clerk-js): cover SessionTokenCache expiry under device clock skew --- .../src/core/__tests__/tokenCache.test.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index 27c0a458401..1bf786de242 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -726,6 +726,63 @@ describe('SessionTokenCache', () => { }); }); + describe('device clock skew', () => { + const tokenId = 'session_123'; + const deviceNow = 1666648260; + const deviceLag = 40; + + it('does not serve a fresh token past its server expiry when the device clock is behind', async () => { + const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60); + + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(freshRaw)) }); + await tick(); + + vi.advanceTimersByTime(50 * 1000); + expect(SessionTokenCache.get({ tokenId })).toBeDefined(); + + vi.advanceTimersByTime(20 * 1000); + expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); + }); + + it('does not serve a broadcast token past its server expiry when the device clock is behind', async () => { + const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60); + + await broadcastListener({ + data: { organizationId: null, sessionId: tokenId, tokenId, tokenRaw: freshRaw, traceId: 'skew' }, + } as MessageEvent); + + vi.advanceTimersByTime(50 * 1000); + expect(SessionTokenCache.get({ tokenId })).toBeDefined(); + + vi.advanceTimersByTime(20 * 1000); + expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); + }); + + it('fires the proactive refresh before server expiry when the device clock is behind', async () => { + const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60); + const onRefresh = vi.fn(); + + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(freshRaw)), onRefresh }); + await tick(); + + vi.advanceTimersByTime(44 * 1000); + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it('does not extend an aged token to a full lifetime from cache insert', async () => { + const agedRaw = createJwtWithOiat(deviceNow - 40, deviceNow - 40, 60); + + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(agedRaw)) }); + await tick(); + + vi.advanceTimersByTime(10 * 1000); + expect(SessionTokenCache.get({ tokenId })).toBeDefined(); + + vi.advanceTimersByTime(11 * 1000); + expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); + }); + }); + describe('broadcast sending', () => { it('broadcasts automatically when token resolves with valid claims', async () => { const futureExp = Math.floor(Date.now() / 1000) + 3600; From 731c3d2e96d1de7855d8c6a3b29aa9a05f908de8 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 23 Oct 2026 14:03:20 -0700 Subject: [PATCH 2/5] fix(clerk-js): expire cached session tokens by the earlier of exp and lifetime SessionTokenCache read the server-issued exp against the device clock, so a device running behind served tokens past their server expiry. Bound the remaining TTL by both exp on the device clock and a full lifetime from cache insert. Broadcast entries now stamp createdAt with the device clock instead of the JWT iat. --- .changeset/token-cache-clock-skew.md | 5 ++++ .../src/core/__tests__/tokenCache.test.ts | 27 +++++++++---------- packages/clerk-js/src/core/tokenCache.ts | 21 +++++++-------- 3 files changed, 26 insertions(+), 27 deletions(-) create mode 100644 .changeset/token-cache-clock-skew.md diff --git a/.changeset/token-cache-clock-skew.md b/.changeset/token-cache-clock-skew.md new file mode 100644 index 00000000000..3055ba906cc --- /dev/null +++ b/.changeset/token-cache-clock-skew.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Fix `getToken()` returning an expired session token when the device clock runs behind the server. Requests from those browsers no longer fail with expired-token errors until the cache evicts the token. diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index 1bf786de242..d807f7cd566 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -235,7 +235,6 @@ describe('SessionTokenCache', () => { await broadcastListener(newerEvent); const resultAfterNewer = SessionTokenCache.get({ tokenId: 'session_123' }); expect(resultAfterNewer).toBeDefined(); - const newerCreatedAt = resultAfterNewer?.entry.createdAt; const olderEvent: MessageEvent = { data: { @@ -252,7 +251,7 @@ describe('SessionTokenCache', () => { const resultAfterOlder = SessionTokenCache.get({ tokenId: 'session_123' }); expect(resultAfterOlder).toBeDefined(); - expect(resultAfterOlder?.entry.createdAt).toBe(newerCreatedAt); + expect(resultAfterOlder?.entry.resolvedToken?.getRawString()).toBe(newerJwt); }); it('enforces monotonicity: replaces older cached token when a fresher-oiat broadcast arrives', async () => { @@ -277,7 +276,7 @@ describe('SessionTokenCache', () => { await broadcastListener(olderEvent); const resultAfterOlder = SessionTokenCache.get({ tokenId: 'session_123' }); expect(resultAfterOlder).toBeDefined(); - expect(resultAfterOlder?.entry.createdAt).toBe(1666648190); + expect(resultAfterOlder?.entry.resolvedToken?.getRawString()).toBe(olderJwt); const newerEvent: MessageEvent = { data: { @@ -294,7 +293,7 @@ describe('SessionTokenCache', () => { const resultAfterNewer = SessionTokenCache.get({ tokenId: 'session_123' }); expect(resultAfterNewer).toBeDefined(); - expect(resultAfterNewer?.entry.createdAt).toBe(1666648250); + expect(resultAfterNewer?.entry.resolvedToken?.getRawString()).toBe(newerJwt); }); it('ignores a broadcast staler than the reconciled resolvedToken even when the resolver is staler', async () => { @@ -320,7 +319,6 @@ describe('SessionTokenCache', () => { await tick(); const beforeEntry = SessionTokenCache.get({ tokenId })?.entry; expect(beforeEntry?.resolvedToken?.getRawString()).toBe(highRaw); - const beforeCreatedAt = beforeEntry?.createdAt; // Broadcast a token staler than high (but fresher than low, so the staler resolver would // have let it through under the bug). @@ -340,9 +338,9 @@ describe('SessionTokenCache', () => { const afterEntry = SessionTokenCache.get({ tokenId })?.entry; expect(afterEntry?.resolvedToken?.getRawString()).toBe(highRaw); - // createdAt unchanged proves the broadcast was dropped before setInternal ran; the bug - // would have replaced the entry, stamping it with the broadcast's iat (1666648220). - expect(afterEntry?.createdAt).toBe(beforeCreatedAt); + // The same entry proves the broadcast was dropped before setInternal ran; the bug + // would have replaced it with the broadcast's entry. + expect(afterEntry).toBe(beforeEntry); }); it('successfully updates cache with valid token', () => { @@ -555,9 +553,8 @@ describe('SessionTokenCache', () => { expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); - // createdAt reflects high's iat (1666648250), not low's (1666648190): the slot's TTL is stamped - // from the published winner. A slot stamped with low's earlier iat would already be evicted at - // this point; one stamped with high's iat is still comfortably within its 120s TTL. + // The slot's TTL comes from the published winner, high (exp 1666648370), not low + // (exp 1666648310). A slot timed from low would already be evicted at this point. vi.advanceTimersByTime(50 * 1000); expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); }); @@ -788,7 +785,7 @@ describe('SessionTokenCache', () => { const futureExp = Math.floor(Date.now() / 1000) + 3600; const tokenResolver = Promise.resolve({ getRawString: () => mockJwt, - jwt: { claims: { exp: futureExp, iat: 1675876730, sid: 'session_123' } }, + jwt: { claims: { exp: futureExp, iat: futureExp - 3600, sid: 'session_123' } }, } as any); SessionTokenCache.set({ @@ -815,7 +812,7 @@ describe('SessionTokenCache', () => { const futureExp = Math.floor(Date.now() / 1000) + 3600; const tokenResolver = Promise.resolve({ getRawString: () => mockJwt, - jwt: { claims: { exp: futureExp, iat: 1675876730 } }, + jwt: { claims: { exp: futureExp, iat: futureExp - 3600 } }, } as any); SessionTokenCache.set({ @@ -832,7 +829,7 @@ describe('SessionTokenCache', () => { const futureExp = Math.floor(Date.now() / 1000) + 3600; const tokenResolver = Promise.resolve({ getRawString: () => mockJwt, - jwt: { claims: { exp: futureExp, iat: 1675876730, sid: 'session_123' } }, + jwt: { claims: { exp: futureExp, iat: futureExp - 3600, sid: 'session_123' } }, } as any); SessionTokenCache.set({ @@ -858,7 +855,7 @@ describe('SessionTokenCache', () => { const futureExp = Math.floor(Date.now() / 1000) + 3600; const tokenResolver = Promise.resolve({ getRawString: () => mockJwt, - jwt: { claims: { exp: futureExp, iat: 1675876730, sid: 'session_123' } }, + jwt: { claims: { exp: futureExp, iat: futureExp - 3600, sid: 'session_123' } }, } as any); expect(() => diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index 4c67ca3aad6..9377ed5e3ae 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -292,7 +292,6 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { setInternal( { - createdAt: iat, tokenId: data.tokenId, tokenResolver: Promise.resolve(token), }, @@ -399,17 +398,15 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { clearTimeout(live.timeoutId); clearTimeout(live.refreshTimeoutId); - const expiresAt = winnerClaims.exp; - const issuedAt = winnerClaims.iat; - const expiresIn: Seconds = expiresAt - issuedAt; - // Timers run relative to now, while createdAt/expiresIn describe the token's - // real validity window for get(). An aged winner (alive for part of its - // lifetime already) must be evicted and refreshed by its real expiry, not a - // full lifetime from now. - const remainingTtl: Seconds = expiresAt - Math.floor(Date.now() / 1000); - - live.createdAt = issuedAt; - live.expiresIn = expiresIn; + // `exp` is server time, so reading it on the device clock is late by the + // device's lag. A full lifetime from cache insert is late by the token's age + // on arrival. The earlier of the two is late only by the smaller error. + const nowSeconds = Math.floor(Date.now() / 1000); + const lifetime: Seconds = winnerClaims.exp - winnerClaims.iat; + const remainingTtl: Seconds = Math.min(winnerClaims.exp - nowSeconds, lifetime - (nowSeconds - live.createdAt)); + + live.createdAt = nowSeconds; + live.expiresIn = remainingTtl; if (remainingTtl <= 0) { dropIfCurrent(live); From da22b849e62c46e0afb37f944b1d6d3a91092f22 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 23 Oct 2026 14:25:30 -0700 Subject: [PATCH 3/5] test(clerk-js): pin SessionTokenCache expiry for fast clocks, staler refetches, and re-caching --- .../src/core/__tests__/tokenCache.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index d807f7cd566..c4c312af49b 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -766,6 +766,52 @@ describe('SessionTokenCache', () => { expect(onRefresh).toHaveBeenCalledTimes(1); }); + it('stops serving a token early, not late, when the device clock is ahead', async () => { + const deviceLead = 30; + const freshRaw = createJwtWithOiat(deviceNow - deviceLead, deviceNow - deviceLead, 60); + + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(freshRaw)) }); + await tick(); + + vi.advanceTimersByTime(20 * 1000); + expect(SessionTokenCache.get({ tokenId })).toBeDefined(); + + vi.advanceTimersByTime(6 * 1000); + expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); + }); + + it('keeps the fresher token expiry when a later fetch resolves with a staler token', async () => { + const fresherRaw = createJwtWithOiat(deviceNow - 10, deviceNow - 10, 60); + const stalerRaw = createJwtWithOiat(deviceNow - 50, deviceNow - 50, 60); + + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(fresherRaw)) }); + await tick(); + + vi.advanceTimersByTime(20 * 1000); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(stalerRaw)) }); + await tick(); + + vi.advanceTimersByTime(20 * 1000); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(fresherRaw); + + vi.advanceTimersByTime(6 * 1000); + expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); + }); + + it('does not extend expiry when the same token is cached again on a synced clock', async () => { + const raw = createJwtWithOiat(deviceNow, deviceNow, 60); + + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(raw)) }); + await tick(); + + vi.advanceTimersByTime(30 * 1000); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(raw)) }); + await tick(); + + vi.advanceTimersByTime(26 * 1000); + expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); + }); + it('does not extend an aged token to a full lifetime from cache insert', async () => { const agedRaw = createJwtWithOiat(deviceNow - 40, deviceNow - 40, 60); From 2ac81f37679c33204037cb725f9f63f428579934 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 23 Oct 2026 14:26:23 -0700 Subject: [PATCH 4/5] refactor(clerk-js): store a device-clock expiry on SessionTokenCache entries Replace createdAt and expiresIn on TokenCacheValue with cachedAt and expiresAt, both on the device clock, so the fields mean what they say. Remove the TokenCacheEntry.createdAt input. No production caller passed it, and a caller passing iat would mix server and device clocks again. cachedAt stays the insert time after the token resolves. --- .../src/core/__tests__/tokenCache.test.ts | 10 +++----- packages/clerk-js/src/core/tokenCache.ts | 25 ++++++------------- 2 files changed, 11 insertions(+), 24 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index c4c312af49b..acced559aa1 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -681,7 +681,7 @@ describe('SessionTokenCache', () => { jwt: { claims: { exp, iat } }, } as any); - SessionTokenCache.set({ createdAt: nowSeconds - 70, tokenId: 'expired_token', tokenResolver }); + SessionTokenCache.set({ tokenId: 'expired_token', tokenResolver }); await tokenResolver; @@ -691,7 +691,7 @@ describe('SessionTokenCache', () => { it('returns token when remaining TTL is above poller interval', async () => { const nowSeconds = Math.floor(Date.now() / 1000); - const iat = nowSeconds; + const iat = nowSeconds - 11; const exp = iat + 20; const soonJwt = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify({ iat, exp }))}.signature`; @@ -700,8 +700,8 @@ describe('SessionTokenCache', () => { jwt: { claims: { exp, iat } }, } as any); - // Token has 20s TTL, created 11s ago = 9s remaining (> 5s poller interval) - SessionTokenCache.set({ createdAt: nowSeconds - 11, tokenId: 'soon_expired_token', tokenResolver }); + // Token has 20s TTL, issued 11s ago = 9s remaining (> 5s poller interval) + SessionTokenCache.set({ tokenId: 'soon_expired_token', tokenResolver }); await tokenResolver; @@ -1909,7 +1909,6 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ audience: undefined, - createdAt: nowSeconds, tokenId: session1Id, tokenResolver: session1Resolver, }); @@ -1969,7 +1968,6 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ audience: undefined, - createdAt: nowSeconds - 10, tokenId: sessionId, tokenResolver: initialResolver, }); diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index 9377ed5e3ae..bb04327332f 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -14,11 +14,6 @@ import { createTokenStore } from './tokenStore'; * Extends TokenCacheKeyJSON with additional properties for expiration tracking and token retrieval. */ interface TokenCacheEntry extends TokenCacheKeyJSON { - /** - * Timestamp in seconds since UNIX epoch when the entry was created. - * Used for expiration and cleanup scheduling. - */ - createdAt?: Seconds; /** * Callback to refresh this token before it expires. * Called by the proactive refresh timer to trigger background refresh. @@ -52,9 +47,9 @@ interface TokenCacheValue { * which is what keeps a staler resolve from overwriting a fresher token. */ baseline?: TokenResource; - createdAt: Seconds; + cachedAt: Seconds; entry: TokenCacheEntry; - expiresIn?: Seconds; + expiresAt?: Seconds; /** Timer for automatic cache cleanup when token expires */ timeoutId?: ReturnType; /** Timer for proactive refresh before token enters leeway period */ @@ -190,9 +185,7 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { return; } - const nowSeconds = Math.floor(Date.now() / 1000); - const elapsed = nowSeconds - value.createdAt; - const remainingTtl = (value.expiresIn ?? Infinity) - elapsed; + const remainingTtl = (value.expiresAt ?? Infinity) - Math.floor(Date.now() / 1000); // Token expired or dangerously close to expiration - force synchronous refresh // Uses poller interval as threshold since the poller might not get to it in time @@ -325,9 +318,7 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { clearTimeout(existing?.timeoutId); clearTimeout(existing?.refreshTimeoutId); - const nowSeconds = Math.floor(Date.now() / 1000); - const createdAt = entry.createdAt ?? nowSeconds; - const value: TokenCacheValue = { createdAt, entry, expiresIn: undefined }; + const value: TokenCacheValue = { cachedAt: Math.floor(Date.now() / 1000), entry }; // Chain the freshest known token across replacements. This never touches // entry.resolvedToken: a pending entry reads as pending and callers await. @@ -401,12 +392,10 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { // `exp` is server time, so reading it on the device clock is late by the // device's lag. A full lifetime from cache insert is late by the token's age // on arrival. The earlier of the two is late only by the smaller error. - const nowSeconds = Math.floor(Date.now() / 1000); - const lifetime: Seconds = winnerClaims.exp - winnerClaims.iat; - const remainingTtl: Seconds = Math.min(winnerClaims.exp - nowSeconds, lifetime - (nowSeconds - live.createdAt)); + const expiresAt: Seconds = Math.min(winnerClaims.exp, live.cachedAt + winnerClaims.exp - winnerClaims.iat); + const remainingTtl: Seconds = expiresAt - Math.floor(Date.now() / 1000); - live.createdAt = nowSeconds; - live.expiresIn = remainingTtl; + live.expiresAt = expiresAt; if (remainingTtl <= 0) { dropIfCurrent(live); From 42608def4fcadc29ba5164d4212b27de5a2564db Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 23 Oct 2026 14:28:25 -0700 Subject: [PATCH 5/5] chore(clerk-js): tighten token cache changeset --- .changeset/token-cache-clock-skew.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/token-cache-clock-skew.md b/.changeset/token-cache-clock-skew.md index 3055ba906cc..5ac16cd8e4f 100644 --- a/.changeset/token-cache-clock-skew.md +++ b/.changeset/token-cache-clock-skew.md @@ -2,4 +2,4 @@ '@clerk/clerk-js': patch --- -Fix `getToken()` returning an expired session token when the device clock runs behind the server. Requests from those browsers no longer fail with expired-token errors until the cache evicts the token. +Fix `getToken()` returning a session token after it expired on the server when the device clock runs behind.