From 123f51b2c5b6358f9e908c756261c0d8771c0524 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Mon, 26 Oct 2026 17:27:08 +0100 Subject: [PATCH 1/3] fix(clerk-js): store token cache expiry on the token, not the slot The device-clock expiry, min(exp, cachedAt + lifetime), was stored per cache slot and computed from that slot's cachedAt. A slot can serve a token it did not fetch, though: a fresher token carried over from an earlier slot, or the same JWT cached again via hydration or a broadcast. Its lifetime was then counted from the later slot, and on a device clock running behind, the token was served after the server stopped accepting it. Compute the expiry once, when a resolver delivers the token, from that resolver's own slot, and keep it with the token as it moves between slots. When the same JWT is seen again, the earlier expiry wins. --- .../src/core/__tests__/tokenCache.test.ts | 183 ++++++++++++++++-- packages/clerk-js/src/core/tokenCache.ts | 78 +++++--- 2 files changed, 212 insertions(+), 49 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index acced559aa1..b3a20218c45 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -51,6 +51,14 @@ const tick = async () => { const makeToken = (raw: string, id = 'session_123') => new Token({ id, jwt: raw, object: 'token' }) as TokenResource; +const deferred = () => { + let resolve!: (token: TokenResource) => void; + const promise = new Promise(r => { + resolve = r; + }); + return { promise, resolve }; +}; + describe('SessionTokenCache', () => { let mockBroadcastChannel: { addEventListener: ReturnType; @@ -429,21 +437,13 @@ describe('SessionTokenCache', () => { describe('same-tab monotonic resolve', () => { const tokenId = 'session_123'; - const deferred = () => { - let resolve!: (token: TokenResource) => void; - const promise = new Promise(r => { - resolve = r; - }); - return { promise, resolve }; - }; - it('keeps the fresher token when a staler set resolves into the slot after it', async () => { const highRaw = createJwtWithOiat(1666648250, 1666648250, 120); const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120); // A fresher token is cached and resolved, then a staler set() replaces the slot and resolves. // The staler resolve reconciles against the previous token (carried onto the new slot's - // baseline at set time) and loses. + // freshest token at set time) and loses. SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)) }); await tick(); SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(lowRaw)) }); @@ -536,8 +536,8 @@ describe('SessionTokenCache', () => { // Two concurrent sets, both pending. The first (fresher) is replaced by the second (staler) // before either resolves. When the fresher one resolves it is now foreign, so it only raises - // the live slot's baseline, leaving resolvedToken undefined. The staler live resolve then - // reconciles against that baseline and the slot publishes the fresher token. + // the live slot's freshest token, leaving resolvedToken undefined. The staler live resolve then + // reconciles against that freshest token and the slot publishes the fresher token. const dHigh = deferred(); const dLow = deferred(); SessionTokenCache.set({ tokenId, tokenResolver: dHigh.promise }); @@ -564,7 +564,7 @@ describe('SessionTokenCache', () => { const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120); // Inverse ordering of the previous test: the staler set is replaced by the fresher one before - // either resolves. The staler foreign resolve lands first and only advances the baseline; the + // either resolves. The staler foreign resolve lands first and only advances the freshest token; the // fresher live resolve then publishes high directly. const dLow = deferred(); const dHigh = deferred(); @@ -588,7 +588,7 @@ describe('SessionTokenCache', () => { // The live slot (d2) resolves staler first and publishes low. The replaced resolver (d1) // resolves fresher afterward. Because the slot is no longer pending, the fresher foreign token // advances resolvedToken to high and re-derives the slot's timers, rather than only raising the - // baseline. + // freshest token. const dHigh = deferred(); const dLow = deferred(); SessionTokenCache.set({ tokenId, tokenResolver: dHigh.promise }); @@ -606,22 +606,22 @@ describe('SessionTokenCache', () => { expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); }); - it('never exposes the baseline while a replacement is pending, then publishes high once it resolves', async () => { + it('never exposes the freshest token while a replacement is pending, then publishes high once it resolves', async () => { const highRaw = createJwtWithOiat(1666648250, 1666648250, 120); const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120); - // The first set resolves to high, so the slot's resolvedToken and baseline are both high. + // The first set resolves to high, so the slot's resolvedToken and freshest token are both high. SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)) }); await tick(); expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); - // A replacement whose resolver never settles must not expose the carried baseline: the slot + // A replacement whose resolver never settles must not expose the carried freshest token: the slot // reads as pending (resolvedToken undefined) so callers keep awaiting the in-flight fetch. const pending = deferred(); SessionTokenCache.set({ tokenId, tokenResolver: pending.promise }); expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken).toBeUndefined(); - // When it finally resolves staler, the own resolve reconciles against the baseline and the slot + // When it finally resolves staler, the own resolve reconciles against the freshest token and the slot // publishes high. pending.resolve(makeToken(lowRaw)); await tick(); @@ -824,6 +824,155 @@ describe('SessionTokenCache', () => { vi.advanceTimersByTime(11 * 1000); expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); }); + + it('keeps the fresher token expiry when it is carried into a later slot', async () => { + const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60); + const stalerRaw = createJwtWithOiat(deviceNow, deviceNow, 60); + + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(freshRaw)) }); + await tick(); + + vi.advanceTimersByTime(30 * 1000); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(stalerRaw)) }); + await tick(); + + vi.advanceTimersByTime(20 * 1000); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(freshRaw); + + vi.advanceTimersByTime(6 * 1000); + expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); + }); + + it('does not extend expiry when the same token is cached again as a new Token', async () => { + const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60); + + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(freshRaw)) }); + await tick(); + + vi.advanceTimersByTime(30 * 1000); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(freshRaw)) }); + await tick(); + + vi.advanceTimersByTime(20 * 1000); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(freshRaw); + + vi.advanceTimersByTime(6 * 1000); + expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); + }); + + it('does not extend expiry when the same token is received by broadcast after a local cache', async () => { + const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60); + + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(freshRaw)) }); + await tick(); + + vi.advanceTimersByTime(30 * 1000); + await broadcastListener({ + data: { organizationId: null, sessionId: tokenId, tokenId, tokenRaw: freshRaw, traceId: 'skew' }, + } as MessageEvent); + await tick(); + + vi.advanceTimersByTime(20 * 1000); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(freshRaw); + + vi.advanceTimersByTime(6 * 1000); + expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); + }); + + it('counts a replaced resolver token lifetime from its own set, not the live slot', async () => { + const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60); + const stalerRaw = createJwtWithOiat(deviceNow, deviceNow, 60); + const dFresh = deferred(); + const dStaler = deferred(); + + SessionTokenCache.set({ tokenId, tokenResolver: dFresh.promise }); + + vi.advanceTimersByTime(10 * 1000); + SessionTokenCache.set({ tokenId, tokenResolver: dStaler.promise }); + + dFresh.resolve(makeToken(freshRaw)); + await tick(); + dStaler.resolve(makeToken(stalerRaw)); + await tick(); + + vi.advanceTimersByTime(40 * 1000); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(freshRaw); + + vi.advanceTimersByTime(6 * 1000); + expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); + }); + + it('gives a fresher token with a different raw string its own lifetime', async () => { + const firstRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60); + const fresherRaw = createJwtWithOiat(deviceNow + 30 + deviceLag, deviceNow + 30 + deviceLag, 60); + + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(firstRaw)) }); + await tick(); + + vi.advanceTimersByTime(30 * 1000); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(fresherRaw)) }); + await tick(); + + vi.advanceTimersByTime(26 * 1000); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(fresherRaw); + + vi.advanceTimersByTime(30 * 1000); + expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); + }); + + it('fires the proactive refresh from the carried token expiry, not the later slot', async () => { + const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60); + const stalerRaw = createJwtWithOiat(deviceNow, deviceNow, 60); + const onRefresh = vi.fn(); + + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(freshRaw)), onRefresh }); + await tick(); + + vi.advanceTimersByTime(30 * 1000); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(stalerRaw)), onRefresh }); + await tick(); + + vi.advanceTimersByTime(12 * 1000); + expect(onRefresh).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1 * 1000); + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it('takes the earlier expiry when a replaced resolver delivers the same token after the slot resolved', async () => { + const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60); + const dFirst = deferred(); + + SessionTokenCache.set({ tokenId, tokenResolver: dFirst.promise }); + + vi.advanceTimersByTime(10 * 1000); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(freshRaw)) }); + await tick(); + + dFirst.resolve(makeToken(freshRaw)); + await tick(); + + vi.advanceTimersByTime(40 * 1000); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(freshRaw); + + vi.advanceTimersByTime(6 * 1000); + expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); + }); + + it('keeps a pending slot when the token carried into it is close to expiry', async () => { + const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60); + + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(freshRaw)) }); + await tick(); + + vi.advanceTimersByTime(50 * 1000); + SessionTokenCache.set({ tokenId, tokenResolver: deferred().promise }); + + vi.advanceTimersByTime(8 * 1000); + const result = SessionTokenCache.get({ tokenId }); + expect(result).toBeDefined(); + expect(result?.entry.resolvedToken).toBeUndefined(); + }); }); describe('broadcast sending', () => { diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index bb04327332f..6c668cb2316 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -34,22 +34,27 @@ interface TokenCacheEntry extends TokenCacheKeyJSON { type Seconds = number; +interface CachedToken { + expiresAt: Seconds; + token: TokenResource; +} + /** * Internal cache value containing the entry, expiration metadata, and timers. */ interface TokenCacheValue { /** - * Freshest claims-valid token observed for this key, chained across set() calls - * and updated by every resolver settle, including resolvers replaced by a newer - * set() while still pending. Internal bookkeeping only: readers only ever see - * entry.resolvedToken, so a pending entry still reads as pending and callers + * Freshest claims-valid token observed for this key, with its expiry fixed when + * the cache first saw it for as long as the cache holds it, chained across set() + * calls and updated by every resolver settle, including resolvers replaced by a + * newer set() while still pending. Internal bookkeeping only: readers only ever + * see entry.resolvedToken, so a pending entry still reads as pending and callers * await its resolver. Folded into resolvedToken when the live entry resolves, * which is what keeps a staler resolve from overwriting a fresher token. */ - baseline?: TokenResource; + freshest?: CachedToken; cachedAt: Seconds; entry: TokenCacheEntry; - expiresAt?: Seconds; /** Timer for automatic cache cleanup when token expires */ timeoutId?: ReturnType; /** Timer for proactive refresh before token enters leeway period */ @@ -132,6 +137,16 @@ const generateTabId = (): string => { return Math.random().toString(36).slice(2); }; +const pickFreshestCachedToken = (existing: CachedToken | undefined, incoming: CachedToken): CachedToken => { + if (!existing) { + return incoming; + } + if (existing.token.getRawString() === incoming.token.getRawString()) { + return existing.expiresAt <= incoming.expiresAt ? existing : incoming; + } + return pickFreshestJwt(existing.token, incoming.token) === existing.token ? existing : incoming; +}; + /** * Creates an in-memory token cache with optional BroadcastChannel synchronization across tabs. * Automatically manages token expiration and cleanup via scheduled timeouts. @@ -185,7 +200,9 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { return; } - const remainingTtl = (value.expiresAt ?? Infinity) - Math.floor(Date.now() / 1000); + const hasResolvedToken = value.entry.resolvedToken !== undefined; + const expiresAt = hasResolvedToken && value.freshest ? value.freshest.expiresAt : Infinity; + const remainingTtl = expiresAt - 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 @@ -322,8 +339,7 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { // Chain the freshest known token across replacements. This never touches // entry.resolvedToken: a pending entry reads as pending and callers await. - const prior = existing?.entry.resolvedToken; - value.baseline = prior ? pickFreshestJwt(existing?.baseline, prior) : existing?.baseline; + value.freshest = existing?.freshest; // Clears both timers and drops the slot, but only if it still holds `target` // (a newer set() may have replaced it while a promise/timer was pending). @@ -352,7 +368,7 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { if (isOwn && !isValid) { // The live slot's own fetch resolved unusable: drop the slot so the next - // read refetches. Keeping the baseline alive here would hide a broken + // read refetches. Keeping the freshest token alive here would hide a broken // token endpoint behind cache hits. dropIfCurrent(live); return; @@ -361,41 +377,38 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { return; } + // `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 candidate: CachedToken = { + expiresAt: Math.min(claims.exp, value.cachedAt + claims.exp - claims.iat), + token: newToken, + }; + // Track the freshest token seen for this key, even when this resolver was // replaced by a newer set() while it was pending. - const baseline = pickFreshestJwt(live.baseline, newToken); - live.baseline = baseline; + const previous = live.freshest; + const winner = pickFreshestCachedToken(previous, candidate); + live.freshest = winner; // resolvedToken is only written once the live slot itself resolves. While // the live slot is pending, readers must keep awaiting its resolver, so a - // replaced resolver may only advance the baseline above. - if (!isOwn && live.entry.resolvedToken === undefined) { + // replaced resolver may only advance the freshest token above. + const hasResolvedToken = live.entry.resolvedToken !== undefined; + if (!isOwn && !hasResolvedToken) { return; } - const winner = pickFreshestJwt(live.entry.resolvedToken, baseline); - if (winner === live.entry.resolvedToken) { + if (hasResolvedToken && winner === previous) { // Nothing advanced; the installed timers already match the winner. return; } - live.entry.resolvedToken = winner; - - const winnerClaims = winner.jwt?.claims; - if (!winnerClaims || typeof winnerClaims.exp !== 'number' || typeof winnerClaims.iat !== 'number') { - dropIfCurrent(live); - return; - } + live.entry.resolvedToken = winner.token; clearTimeout(live.timeoutId); clearTimeout(live.refreshTimeoutId); - // `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 expiresAt: Seconds = Math.min(winnerClaims.exp, live.cachedAt + winnerClaims.exp - winnerClaims.iat); - const remainingTtl: Seconds = expiresAt - Math.floor(Date.now() / 1000); - - live.expiresAt = expiresAt; + const remainingTtl: Seconds = winner.expiresAt - Math.floor(Date.now() / 1000); if (remainingTtl <= 0) { dropIfCurrent(live); @@ -437,8 +450,9 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { // Best-effort side effect: a broadcast failure (e.g. postMessage racing a // channel close) must not reach the outer catch and evict the cached token (SDK-119). try { - const tokenRaw = winner.getRawString(); - if (tokenRaw && winnerClaims.sid) { + const tokenRaw = winner.token.getRawString(); + const winnerClaims = winner.token.jwt?.claims; + if (tokenRaw && winnerClaims?.sid) { const sessionId = winnerClaims.sid; const organizationId = winnerClaims.org_id || (winnerClaims.o as any)?.id; const template = TokenId.extractTemplate(entry.tokenId, sessionId, organizationId); From d28b9b991abd10ba3256bb88af7e76c44181f76b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 27 Oct 2026 14:57:40 +0100 Subject: [PATCH 2/3] Clarify naming --- packages/clerk-js/src/core/tokenCache.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index 6c668cb2316..d5edee93df3 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -34,7 +34,7 @@ interface TokenCacheEntry extends TokenCacheKeyJSON { type Seconds = number; -interface CachedToken { +interface TokenWithExpiry { expiresAt: Seconds; token: TokenResource; } @@ -52,7 +52,7 @@ interface TokenCacheValue { * await its resolver. Folded into resolvedToken when the live entry resolves, * which is what keeps a staler resolve from overwriting a fresher token. */ - freshest?: CachedToken; + freshestKnown?: TokenWithExpiry; cachedAt: Seconds; entry: TokenCacheEntry; /** Timer for automatic cache cleanup when token expires */ @@ -137,7 +137,7 @@ const generateTabId = (): string => { return Math.random().toString(36).slice(2); }; -const pickFreshestCachedToken = (existing: CachedToken | undefined, incoming: CachedToken): CachedToken => { +const pickFreshest = (existing: TokenWithExpiry | undefined, incoming: TokenWithExpiry): TokenWithExpiry => { if (!existing) { return incoming; } @@ -201,7 +201,7 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { } const hasResolvedToken = value.entry.resolvedToken !== undefined; - const expiresAt = hasResolvedToken && value.freshest ? value.freshest.expiresAt : Infinity; + const expiresAt = hasResolvedToken && value.freshestKnown ? value.freshestKnown.expiresAt : Infinity; const remainingTtl = expiresAt - Math.floor(Date.now() / 1000); // Token expired or dangerously close to expiration - force synchronous refresh @@ -339,7 +339,7 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { // Chain the freshest known token across replacements. This never touches // entry.resolvedToken: a pending entry reads as pending and callers await. - value.freshest = existing?.freshest; + value.freshestKnown = existing?.freshestKnown; // Clears both timers and drops the slot, but only if it still holds `target` // (a newer set() may have replaced it while a promise/timer was pending). @@ -380,16 +380,16 @@ 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 candidate: CachedToken = { + const candidate: TokenWithExpiry = { expiresAt: Math.min(claims.exp, value.cachedAt + claims.exp - claims.iat), token: newToken, }; // Track the freshest token seen for this key, even when this resolver was // replaced by a newer set() while it was pending. - const previous = live.freshest; - const winner = pickFreshestCachedToken(previous, candidate); - live.freshest = winner; + const previous = live.freshestKnown; + const winner = pickFreshest(previous, candidate); + live.freshestKnown = winner; // resolvedToken is only written once the live slot itself resolves. While // the live slot is pending, readers must keep awaiting its resolver, so a From fd102d7303809e024788d0bac0c90379699f6ca7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 27 Oct 2026 21:07:19 +0100 Subject: [PATCH 3/3] Add changeset --- .changeset/token-cache-expiry-on-token.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/token-cache-expiry-on-token.md diff --git a/.changeset/token-cache-expiry-on-token.md b/.changeset/token-cache-expiry-on-token.md new file mode 100644 index 00000000000..1d26cd64772 --- /dev/null +++ b/.changeset/token-cache-expiry-on-token.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Fix `getToken()` returning an expired session token when the device clock runs behind and a token is cached again, or a staler token arrives after a fresher one.