From 9ad4e55e821040fac15aab3913b5f65f90395031 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 18 Mar 2026 15:24:10 +0200 Subject: [PATCH 01/20] feat(shared): Add oiat field to JwtHeader type Session Minter uses oiat (original_issued_at) in the JWT header to track when token claims were last assembled from the DB. Edge re-mints copy this value forward, so consumers can determine claim freshness regardless of how many times the token was re-signed. Marked @internal so developers don't depend on this field. --- .changeset/session-minter-oiat-type.md | 5 +++++ packages/shared/src/types/jwtv2.ts | 2 ++ 2 files changed, 7 insertions(+) create mode 100644 .changeset/session-minter-oiat-type.md diff --git a/.changeset/session-minter-oiat-type.md b/.changeset/session-minter-oiat-type.md new file mode 100644 index 00000000000..860ee52193c --- /dev/null +++ b/.changeset/session-minter-oiat-type.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': patch +--- + +Add `oiat` (original_issued_at) field to `JwtHeader` type for Session Minter monotonic token freshness checks. diff --git a/packages/shared/src/types/jwtv2.ts b/packages/shared/src/types/jwtv2.ts index f03d6738333..1d8af24d979 100644 --- a/packages/shared/src/types/jwtv2.ts +++ b/packages/shared/src/types/jwtv2.ts @@ -25,6 +25,8 @@ export interface JwtHeader { 'x5t#S256'?: string; x5t?: string; x5c?: string | string[]; + /** @internal - used by Session Minter for monotonic token freshness checks. Do not depend on this field. */ + oiat?: number; } declare global { From 65b4a93494640819290a074e6a1b65996f99b768 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 17 Mar 2026 17:05:29 +0200 Subject: [PATCH 02/20] feat(clerk-js): Monotonic token replacement based on oiat Prevent multi-tab race conditions where an edge-minted token with stale claims overwrites a fresher DB-minted token. Uses `oiat ?? iat` as the claim freshness metric. A token with oiat (JWT header) uses oiat as its claim freshness. A token without oiat is origin-minted (coupled FF), so iat represents claim freshness. Four guard points: 1. tokenCache handleBroadcastMessage - replaces old iat comparison 2. tokenCache setInternal - async compare-and-swap at resolve time 3. Session #dispatchTokenEvents - before token:update emit 4. AuthCookieService updateSessionCookie - cookie chokepoint with session scoping (different sessions always allowed through) Guard 4 catches the sleeping tab edge case where in-memory guards pass (stale baseline) but the cookie has a fresher value from another tab. --- .../src/core/__tests__/tokenFreshness.test.ts | 96 +++++++++++++++++++ .../src/core/auth/AuthCookieService.ts | 25 +++++ .../clerk-js/src/core/resources/Session.ts | 12 ++- .../core/resources/__tests__/Session.test.ts | 4 +- packages/clerk-js/src/core/tokenCache.ts | 17 +++- packages/clerk-js/src/core/tokenFreshness.ts | 61 ++++++++++++ 6 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts create mode 100644 packages/clerk-js/src/core/tokenFreshness.ts diff --git a/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts b/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts new file mode 100644 index 00000000000..ce826dc2322 --- /dev/null +++ b/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts @@ -0,0 +1,96 @@ +import type { TokenResource } from '@clerk/shared/types'; +import { describe, expect, it } from 'vitest'; + +import { claimFreshness, shouldRejectToken } from '../tokenFreshness'; + +function makeToken(opts: { oiat?: number; iat?: number } = {}): TokenResource { + return { + jwt: { + header: { alg: 'RS256', kid: 'kid_1', ...(opts.oiat != null ? { oiat: opts.oiat } : {}) }, + claims: { ...(opts.iat != null ? { iat: opts.iat } : {}) }, + }, + getRawString: () => 'mock-jwt', + } as unknown as TokenResource; +} + +describe('claimFreshness', () => { + it('returns oiat when present', () => { + expect(claimFreshness(makeToken({ oiat: 100, iat: 200 }))).toBe(100); + }); + + it('returns iat when oiat is absent', () => { + expect(claimFreshness(makeToken({ iat: 200 }))).toBe(200); + }); + + it('returns undefined when input has no jwt', () => { + expect(claimFreshness(undefined)).toBeUndefined(); + }); +}); + +describe('shouldRejectToken', () => { + describe('both have oiat', () => { + it('row 1: rejects when existing oiat > incoming oiat', () => { + expect(shouldRejectToken(makeToken({ oiat: 100 }), makeToken({ oiat: 90 }))).toBe(true); + }); + + it('row 2: accepts when existing oiat < incoming oiat', () => { + expect(shouldRejectToken(makeToken({ oiat: 90 }), makeToken({ oiat: 100 }))).toBe(false); + }); + + it('row 3: rejects when oiat equal and existing iat > incoming iat', () => { + expect(shouldRejectToken(makeToken({ oiat: 100, iat: 200 }), makeToken({ oiat: 100, iat: 150 }))).toBe(true); + }); + + it('row 4: accepts when oiat equal and existing iat < incoming iat', () => { + expect(shouldRejectToken(makeToken({ oiat: 100, iat: 150 }), makeToken({ oiat: 100, iat: 200 }))).toBe(false); + }); + + it('row 5: rejects when oiat equal and iat equal (keep existing)', () => { + expect(shouldRejectToken(makeToken({ oiat: 100, iat: 150 }), makeToken({ oiat: 100, iat: 150 }))).toBe(true); + }); + }); + + describe('one has oiat, one does not', () => { + it('row 6: accepts when existing oiat < incoming iat', () => { + expect(shouldRejectToken(makeToken({ oiat: 100 }), makeToken({ iat: 120 }))).toBe(false); + }); + + it('row 7: rejects when existing oiat > incoming iat', () => { + expect(shouldRejectToken(makeToken({ oiat: 100 }), makeToken({ iat: 80 }))).toBe(true); + }); + + it('row 8: accepts when existing oiat == incoming iat (different regimes, favor movement)', () => { + expect(shouldRejectToken(makeToken({ oiat: 100 }), makeToken({ iat: 100 }))).toBe(false); + }); + + it('row 9: rejects when existing iat > incoming oiat', () => { + expect(shouldRejectToken(makeToken({ iat: 150 }), makeToken({ oiat: 100 }))).toBe(true); + }); + + it('row 10: accepts when existing iat < incoming oiat', () => { + expect(shouldRejectToken(makeToken({ iat: 90 }), makeToken({ oiat: 100 }))).toBe(false); + }); + + it('row 11: accepts when existing iat == incoming oiat (different regimes, favor movement)', () => { + expect(shouldRejectToken(makeToken({ iat: 100 }), makeToken({ oiat: 100 }))).toBe(false); + }); + }); + + describe('neither has oiat', () => { + it('row 12: rejects when existing iat > incoming iat', () => { + expect(shouldRejectToken(makeToken({ iat: 200 }), makeToken({ iat: 150 }))).toBe(true); + }); + + it('row 13: accepts when existing iat < incoming iat', () => { + expect(shouldRejectToken(makeToken({ iat: 150 }), makeToken({ iat: 200 }))).toBe(false); + }); + + it('row 14: rejects when iat equal (keep existing, avoid churn)', () => { + expect(shouldRejectToken(makeToken({ iat: 150 }), makeToken({ iat: 150 }))).toBe(true); + }); + + it("row 15: accepts when both iat null (can't compare, accept)", () => { + expect(shouldRejectToken(makeToken(), makeToken())).toBe(false); + }); + }); +}); diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index 7a384a70866..cafe6fdef1b 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -13,7 +13,9 @@ import type { Clerk, InstanceType } from '@clerk/shared/types'; import { noop } from '@clerk/shared/utils'; import { debugLogger } from '@/utils/debug'; +import { decode } from '@/utils/jwt'; +import { claimFreshness } from '../tokenFreshness'; import { clerkMissingDevBrowser } from '../errors'; import { eventBus, events } from '../events'; import type { FapiClient } from '../fapiClient'; @@ -194,6 +196,29 @@ export class AuthCookieService { return; } + // Monotonic freshness guard: don't regress the cookie within the same session + if (token) { + const currentRaw = this.sessionCookie.get(); + if (currentRaw) { + try { + const current = decode(currentRaw); + const incoming = decode(token); + const currentSid = current.claims.sid; + const incomingSid = incoming.claims.sid; + // Only apply within the same session. Different sessions always allowed. + if (currentSid && incomingSid && currentSid === incomingSid) { + const currentFresh = claimFreshness(current); + const incomingFresh = claimFreshness(incoming); + if (currentFresh != null && incomingFresh != null && currentFresh > incomingFresh) { + return; + } + } + } catch { + // If decode fails, allow the write (don't block on malformed tokens) + } + } + } + if (!token && !isValidBrowserOnline()) { debugLogger.warn('Removing session cookie (offline)', { sessionId: this.clerk.session?.id }, 'authCookieService'); } diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index ea5e796dbb8..023b644ad70 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -41,6 +41,7 @@ import type { } from '@clerk/shared/types'; import { isWebAuthnSupported as isWebAuthnSupportedOnWindow } from '@clerk/shared/webauthn'; +import { shouldRejectToken } from '@/core/tokenFreshness'; import { unixEpochToDate } from '@/utils/date'; import { debugLogger } from '@/utils/debug'; import { TokenId } from '@/utils/tokenId'; @@ -455,7 +456,10 @@ export class Session extends BaseResource implements SessionResource { // 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 }); + const reject = this.lastActiveToken && shouldRejectToken(this.lastActiveToken, cachedToken); + if (!reject) { + eventBus.emit(events.TokenUpdate, { token: cachedToken }); + } } result = cachedToken.getRawString() || null; } else if (!isBrowserOnline()) { @@ -504,6 +508,12 @@ export class Session extends BaseResource implements SessionResource { return; } + if (this.lastActiveToken) { + if (shouldRejectToken(this.lastActiveToken, token)) { + return; + } + } + eventBus.emit(events.TokenUpdate, { token }); if (token.jwt) { diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 4ccae5510e2..5e49e46908b 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -98,7 +98,9 @@ describe('Session', () => { expect(BaseResource.clerk.getFapiClient().request).not.toHaveBeenCalled(); expect(token).toEqual(mockJwt); - expect(dispatchSpy).toHaveBeenCalledTimes(2); + // Cache hits with the same token as lastActiveToken suppress re-emission + // to avoid unnecessary cookie writes (monotonic freshness guard). + expect(dispatchSpy).toHaveBeenCalledTimes(0); }); it('returns same token without API call when Session is reconstructed', async () => { diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index 98bfaa25fae..71d9601a9fa 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -5,6 +5,7 @@ import { TokenId } from '@/utils/tokenId'; import { POLLER_INTERVAL_IN_MS } from './auth/SessionCookiePoller'; import { Token } from './resources/internal'; +import { shouldRejectToken } from './tokenFreshness'; /** * Identifies a cached token entry by tokenId and optional audience. @@ -288,11 +289,10 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const result = get({ tokenId: data.tokenId }); if (result) { const existingToken = await result.entry.tokenResolver; - const existingIat = existingToken.jwt?.claims?.iat; - if (existingIat && existingIat >= iat) { + if (shouldRejectToken(existingToken, token)) { debugLogger.debug( - 'Ignoring older token broadcast', - { existingIat, incomingIat: iat, tabId, tokenId: data.tokenId, traceId: data.traceId }, + 'Ignoring staler token broadcast', + { tokenId: data.tokenId, traceId: data.traceId }, 'tokenCache', ); return; @@ -369,6 +369,15 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { entry.tokenResolver .then(newToken => { + // Compare-and-swap: if another concurrent resolve already committed + // a fresher token for this key, don't overwrite it. + const currentValue = cache.get(key); + if (currentValue?.entry?.resolvedToken && newToken) { + if (shouldRejectToken(currentValue.entry.resolvedToken, newToken)) { + return; + } + } + // Store resolved token for synchronous reads entry.resolvedToken = newToken; diff --git a/packages/clerk-js/src/core/tokenFreshness.ts b/packages/clerk-js/src/core/tokenFreshness.ts new file mode 100644 index 00000000000..6203a877d4b --- /dev/null +++ b/packages/clerk-js/src/core/tokenFreshness.ts @@ -0,0 +1,61 @@ +import type { JWT, TokenResource } from '@clerk/shared/types'; + +/** + * Returns the claim freshness of a token or raw JWT. + * + * - If the token has `oiat` (JWT header): that's when claims were last assembled from the DB. + * Edge re-mints copy this value forward, so iat can be recent while oiat is old. + * - If the token has no `oiat`: it's origin-minted (coupled FF means no Session Minter), + * so iat IS when claims were last read from the DB. + * + * @internal + */ +export function claimFreshness(input: TokenResource | JWT | undefined | null): number | undefined { + if (!input) { + return undefined; + } + // TokenResource has .jwt wrapping the JWT; raw JWT has .header directly + const jwt = 'getRawString' in input ? input.jwt : input; + return jwt?.header?.oiat ?? jwt?.claims?.iat; +} + +/** + * Determines whether an incoming token should be rejected in favor of the existing one. + * Returns true if the incoming token is staler than the existing one. + * + * @internal + */ +export function shouldRejectToken(existing: TokenResource, incoming: TokenResource): boolean { + const existingFreshness = claimFreshness(existing); + const incomingFreshness = claimFreshness(incoming); + + // Can't determine freshness: accept incoming as safe default + if (existingFreshness == null || incomingFreshness == null) { + return false; + } + + // Different freshness: the fresher token wins + if (existingFreshness > incomingFreshness) { + return true; + } + if (existingFreshness < incomingFreshness) { + return false; + } + + // Equal freshness: tie-break depends on regime + const existingHasOiat = existing.jwt?.header?.oiat != null; + const incomingHasOiat = incoming.jwt?.header?.oiat != null; + const sameRegime = existingHasOiat === incomingHasOiat; + + if (sameRegime) { + // Same regime, equal freshness. + // Both have oiat: tie-break by iat (more recent mint wins). Equal iat: keep existing. + // Neither has oiat: both origin, same DB snapshot. Keep existing (avoid churn). + const existingIat = existing.jwt?.claims?.iat ?? 0; + const incomingIat = incoming.jwt?.claims?.iat ?? 0; + return existingIat >= incomingIat; + } + + // Different regimes, equal freshness. Transition is happening. Favor incoming. + return false; +} From 91b664b175ee1f03b568c7d38e44fccd261e66b8 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 18 Mar 2026 11:58:28 +0200 Subject: [PATCH 03/20] chore: Add changesets and plan doc for Session Minter SDK changes --- .changeset/session-minter-monotonic-guard.md | 5 +++++ .changeset/session-minter-sdk-params.md | 6 ++++++ 2 files changed, 11 insertions(+) create mode 100644 .changeset/session-minter-monotonic-guard.md create mode 100644 .changeset/session-minter-sdk-params.md diff --git a/.changeset/session-minter-monotonic-guard.md b/.changeset/session-minter-monotonic-guard.md new file mode 100644 index 00000000000..2b64724cfc1 --- /dev/null +++ b/.changeset/session-minter-monotonic-guard.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Add monotonic token replacement based on `oiat` to prevent edge-minted tokens with stale claims from overwriting fresher DB-minted tokens in multi-tab scenarios. diff --git a/.changeset/session-minter-sdk-params.md b/.changeset/session-minter-sdk-params.md new file mode 100644 index 00000000000..29dc8b78d96 --- /dev/null +++ b/.changeset/session-minter-sdk-params.md @@ -0,0 +1,6 @@ +--- +'@clerk/shared': patch +'@clerk/clerk-js': patch +--- + +Add `oiat` field to `JwtHeader` type. Send previous session token and `force_origin` param on `/tokens` requests to support Session Minter edge token minting. From ee8afe8789d4bd46132ba601dfe5d2af2945a439 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 12 May 2026 15:16:07 +0300 Subject: [PATCH 04/20] fix(clerk-js): use shouldRejectToken at cookie write path AuthCookieService was using a bare currentFresh > incomingFresh comparison, which misses the equal-oiat tie-break by iat. An edge re-mint with the same oiat but newer iat would still get rejected, and a same-oiat older-iat token could still overwrite the cookie. Switch to the shared shouldRejectToken so all three guard sites (broadcast handler, Session resource, cookie write) use the same comparator with iat tie-break. Widen shouldRejectToken to accept a raw decoded JWT in addition to TokenResource, since the cookie path decodes the cookie string directly rather than going through the cache. Document why handshake-installed __session cookies are intentionally not gated here. --- .../src/core/auth/AuthCookieService.ts | 10 +++---- packages/clerk-js/src/core/tokenFreshness.ts | 30 +++++++++++++++---- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index 1871001a011..6050d135f3c 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -15,7 +15,7 @@ import { noop } from '@clerk/shared/utils'; import { debugLogger } from '@/utils/debug'; import { decode } from '@/utils/jwt'; -import { claimFreshness } from '../tokenFreshness'; +import { shouldRejectToken } from '../tokenFreshness'; import { clerkMissingDevBrowser } from '../errors'; import { eventBus, events } from '../events'; import type { FapiClient } from '../fapiClient'; @@ -196,7 +196,9 @@ export class AuthCookieService { return; } - // Monotonic freshness guard: don't regress the cookie within the same session + // Monotonic freshness guard: don't regress the cookie within the same session. + // Uses shouldRejectToken so equal-oiat tokens still tie-break by iat (newer wins), + // matching the comparator used at the broadcast handler and Session resource. if (token) { const currentRaw = this.sessionCookie.get(); if (currentRaw) { @@ -207,9 +209,7 @@ export class AuthCookieService { const incomingSid = incoming.claims.sid; // Only apply within the same session. Different sessions always allowed. if (currentSid && incomingSid && currentSid === incomingSid) { - const currentFresh = claimFreshness(current); - const incomingFresh = claimFreshness(incoming); - if (currentFresh != null && incomingFresh != null && currentFresh > incomingFresh) { + if (shouldRejectToken(current, incoming)) { return; } } diff --git a/packages/clerk-js/src/core/tokenFreshness.ts b/packages/clerk-js/src/core/tokenFreshness.ts index 6203a877d4b..2e2b0336150 100644 --- a/packages/clerk-js/src/core/tokenFreshness.ts +++ b/packages/clerk-js/src/core/tokenFreshness.ts @@ -19,13 +19,29 @@ export function claimFreshness(input: TokenResource | JWT | undefined | null): n return jwt?.header?.oiat ?? jwt?.claims?.iat; } +/** + * Extracts the underlying JWT from a TokenResource wrapper, or returns the JWT as-is. + */ +function asJwt(input: TokenResource | JWT): JWT | undefined { + return 'getRawString' in input ? input.jwt : input; +} + /** * Determines whether an incoming token should be rejected in favor of the existing one. - * Returns true if the incoming token is staler than the existing one. + * Returns true if the incoming token is staler than the existing one. Accepts either + * TokenResource (cache layer) or a raw decoded JWT (cookie layer). + * + * Notes on coverage: enforced at /tokens responses, broadcast events, and cookie writes. + * Handshake-installed __session cookies are intentionally NOT gated here: handshake is + * a redirect-based full auth state resync, the browser commits the Set-Cookie before + * any SDK code runs, and there is no in-flight race window for the gate to protect. * * @internal */ -export function shouldRejectToken(existing: TokenResource, incoming: TokenResource): boolean { +export function shouldRejectToken( + existing: TokenResource | JWT, + incoming: TokenResource | JWT, +): boolean { const existingFreshness = claimFreshness(existing); const incomingFreshness = claimFreshness(incoming); @@ -43,16 +59,18 @@ export function shouldRejectToken(existing: TokenResource, incoming: TokenResour } // Equal freshness: tie-break depends on regime - const existingHasOiat = existing.jwt?.header?.oiat != null; - const incomingHasOiat = incoming.jwt?.header?.oiat != null; + const existingJwt = asJwt(existing); + const incomingJwt = asJwt(incoming); + const existingHasOiat = existingJwt?.header?.oiat != null; + const incomingHasOiat = incomingJwt?.header?.oiat != null; const sameRegime = existingHasOiat === incomingHasOiat; if (sameRegime) { // Same regime, equal freshness. // Both have oiat: tie-break by iat (more recent mint wins). Equal iat: keep existing. // Neither has oiat: both origin, same DB snapshot. Keep existing (avoid churn). - const existingIat = existing.jwt?.claims?.iat ?? 0; - const incomingIat = incoming.jwt?.claims?.iat ?? 0; + const existingIat = existingJwt?.claims?.iat ?? 0; + const incomingIat = incomingJwt?.claims?.iat ?? 0; return existingIat >= incomingIat; } From f56f2749f6a5fbafa3d94db28eac2fe7a3102f08 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 12 May 2026 15:17:39 +0300 Subject: [PATCH 05/20] test(clerk-js): cover JWT-input path and oiat:0 legacy in tokenFreshness shouldRejectToken now accepts a raw decoded JWT (for the cookie write path) in addition to TokenResource (for the cache + broadcast paths). Add tests for the new signature, plus a clarifying case for legacy tokens where oiat is literally 0 (counts as present, freshness == 0) or absent (falls back to iat). --- .../src/core/__tests__/tokenFreshness.test.ts | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts b/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts index ce826dc2322..78539623eb0 100644 --- a/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts @@ -1,4 +1,4 @@ -import type { TokenResource } from '@clerk/shared/types'; +import type { JWT, TokenResource } from '@clerk/shared/types'; import { describe, expect, it } from 'vitest'; import { claimFreshness, shouldRejectToken } from '../tokenFreshness'; @@ -13,6 +13,13 @@ function makeToken(opts: { oiat?: number; iat?: number } = {}): TokenResource { } as unknown as TokenResource; } +function makeJwt(opts: { oiat?: number; iat?: number } = {}): JWT { + return { + header: { alg: 'RS256', kid: 'kid_1', ...(opts.oiat != null ? { oiat: opts.oiat } : {}) }, + claims: { ...(opts.iat != null ? { iat: opts.iat } : {}) }, + } as unknown as JWT; +} + describe('claimFreshness', () => { it('returns oiat when present', () => { expect(claimFreshness(makeToken({ oiat: 100, iat: 200 }))).toBe(100); @@ -93,4 +100,36 @@ describe('shouldRejectToken', () => { expect(shouldRejectToken(makeToken(), makeToken())).toBe(false); }); }); + + describe('JWT input (cookie path)', () => { + it('accepts a raw JWT for both arguments', () => { + expect(shouldRejectToken(makeJwt({ oiat: 100 }), makeJwt({ oiat: 200 }))).toBe(false); + expect(shouldRejectToken(makeJwt({ oiat: 200 }), makeJwt({ oiat: 100 }))).toBe(true); + }); + + it('mixes TokenResource and raw JWT inputs', () => { + expect(shouldRejectToken(makeToken({ oiat: 100 }), makeJwt({ oiat: 200 }))).toBe(false); + expect(shouldRejectToken(makeJwt({ oiat: 200 }), makeToken({ oiat: 100 }))).toBe(true); + }); + + it('tie-breaks by iat on equal oiat for raw JWT inputs', () => { + expect(shouldRejectToken(makeJwt({ oiat: 100, iat: 150 }), makeJwt({ oiat: 100, iat: 200 }))).toBe(false); + expect(shouldRejectToken(makeJwt({ oiat: 100, iat: 200 }), makeJwt({ oiat: 100, iat: 150 }))).toBe(true); + }); + }); + + describe('legacy oiat: 0', () => { + // oiat is set by origin only; legacy tokens minted before the feature flag + // landed will have no oiat field. A token with oiat literally === 0 is also + // possible if a clock test mints during epoch (or if origin malfunctions). + it('treats oiat: 0 as missing oiat and falls back to iat', () => { + // 0 is falsy in JS; claimFreshness uses ?? so 0 IS a valid oiat. + // But tie-break logic checks `oiat != null`, so 0 counts as present. + expect(claimFreshness(makeJwt({ oiat: 0, iat: 100 }))).toBe(0); + }); + + it('legacy (no oiat) older token loses to newer token', () => { + expect(shouldRejectToken(makeJwt({ iat: 100 }), makeJwt({ iat: 200 }))).toBe(false); + }); + }); }); From 153e99d1eb68263b44a5bb101191a479e37631bb Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 12 May 2026 15:27:34 +0300 Subject: [PATCH 06/20] test(clerk-js): fix misleading oiat: 0 test label The label said 'missing oiat' but the implementation treats numeric 0 as present (claimFreshness uses ??). Rename to 'oiat: 0 as PRESENT (numeric zero, not missing)' and tighten the explanatory comment. --- .../clerk-js/src/core/__tests__/tokenFreshness.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts b/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts index 78539623eb0..d39f7000990 100644 --- a/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts @@ -118,13 +118,13 @@ describe('shouldRejectToken', () => { }); }); - describe('legacy oiat: 0', () => { + describe('legacy and edge cases', () => { // oiat is set by origin only; legacy tokens minted before the feature flag // landed will have no oiat field. A token with oiat literally === 0 is also // possible if a clock test mints during epoch (or if origin malfunctions). - it('treats oiat: 0 as missing oiat and falls back to iat', () => { - // 0 is falsy in JS; claimFreshness uses ?? so 0 IS a valid oiat. - // But tie-break logic checks `oiat != null`, so 0 counts as present. + it('treats oiat: 0 as PRESENT (numeric zero, not missing)', () => { + // claimFreshness uses ?? so 0 is a valid oiat. Tie-break logic checks + // `oiat != null`, which treats 0 as present. expect(claimFreshness(makeJwt({ oiat: 0, iat: 100 }))).toBe(0); }); From 6a144bb5f8625adcb52a9512a840b738157e87ba Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 13 May 2026 15:39:22 +0300 Subject: [PATCH 07/20] refactor(clerk-js): simplify shouldRejectToken; oiat is now universal All origin-minted tokens carry the oiat JWT header now, so the regime- bridging cases (one side with oiat, one without) are unreachable in steady state. The previous 15-row decision table compressed to: 1. Both have oiat: compare oiat, tie-break by iat (existing wins on tie) 2. Legacy safety net: a token without oiat is from a pre-feature codebase and is by definition staler than any oiat-bearing token 3. Both legacy: cannot rank, accept incoming as safe default Dropped the unused `claimFreshness` export (no external consumers after the cookie-guard fix swapped to shouldRejectToken). Cut tests for the 9 unreachable rows; kept the 6 reachable cases plus 3 legacy safety-net cases and 3 JWT-input cases (cookie path). --- .../src/core/__tests__/tokenFreshness.test.ts | 91 ++++--------------- packages/clerk-js/src/core/tokenFreshness.ts | 76 ++++++---------- 2 files changed, 45 insertions(+), 122 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts b/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts index d39f7000990..ade537f82ff 100644 --- a/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts @@ -1,7 +1,7 @@ import type { JWT, TokenResource } from '@clerk/shared/types'; import { describe, expect, it } from 'vitest'; -import { claimFreshness, shouldRejectToken } from '../tokenFreshness'; +import { shouldRejectToken } from '../tokenFreshness'; function makeToken(opts: { oiat?: number; iat?: number } = {}): TokenResource { return { @@ -20,89 +20,51 @@ function makeJwt(opts: { oiat?: number; iat?: number } = {}): JWT { } as unknown as JWT; } -describe('claimFreshness', () => { - it('returns oiat when present', () => { - expect(claimFreshness(makeToken({ oiat: 100, iat: 200 }))).toBe(100); - }); - - it('returns iat when oiat is absent', () => { - expect(claimFreshness(makeToken({ iat: 200 }))).toBe(200); - }); - - it('returns undefined when input has no jwt', () => { - expect(claimFreshness(undefined)).toBeUndefined(); - }); -}); - describe('shouldRejectToken', () => { - describe('both have oiat', () => { - it('row 1: rejects when existing oiat > incoming oiat', () => { + describe('both have oiat (the only reachable path post-rollout)', () => { + it('rejects when existing oiat > incoming oiat', () => { expect(shouldRejectToken(makeToken({ oiat: 100 }), makeToken({ oiat: 90 }))).toBe(true); }); - it('row 2: accepts when existing oiat < incoming oiat', () => { + it('accepts when existing oiat < incoming oiat', () => { expect(shouldRejectToken(makeToken({ oiat: 90 }), makeToken({ oiat: 100 }))).toBe(false); }); - it('row 3: rejects when oiat equal and existing iat > incoming iat', () => { + it('rejects when oiat equal and existing iat > incoming iat', () => { expect(shouldRejectToken(makeToken({ oiat: 100, iat: 200 }), makeToken({ oiat: 100, iat: 150 }))).toBe(true); }); - it('row 4: accepts when oiat equal and existing iat < incoming iat', () => { + it('accepts when oiat equal and existing iat < incoming iat', () => { expect(shouldRejectToken(makeToken({ oiat: 100, iat: 150 }), makeToken({ oiat: 100, iat: 200 }))).toBe(false); }); - it('row 5: rejects when oiat equal and iat equal (keep existing)', () => { + it('rejects when oiat equal and iat equal (identical, keep existing)', () => { expect(shouldRejectToken(makeToken({ oiat: 100, iat: 150 }), makeToken({ oiat: 100, iat: 150 }))).toBe(true); }); - }); - - describe('one has oiat, one does not', () => { - it('row 6: accepts when existing oiat < incoming iat', () => { - expect(shouldRejectToken(makeToken({ oiat: 100 }), makeToken({ iat: 120 }))).toBe(false); - }); - it('row 7: rejects when existing oiat > incoming iat', () => { - expect(shouldRejectToken(makeToken({ oiat: 100 }), makeToken({ iat: 80 }))).toBe(true); - }); - - it('row 8: accepts when existing oiat == incoming iat (different regimes, favor movement)', () => { - expect(shouldRejectToken(makeToken({ oiat: 100 }), makeToken({ iat: 100 }))).toBe(false); - }); - - it('row 9: rejects when existing iat > incoming oiat', () => { - expect(shouldRejectToken(makeToken({ iat: 150 }), makeToken({ oiat: 100 }))).toBe(true); - }); - - it('row 10: accepts when existing iat < incoming oiat', () => { - expect(shouldRejectToken(makeToken({ iat: 90 }), makeToken({ oiat: 100 }))).toBe(false); - }); - - it('row 11: accepts when existing iat == incoming oiat (different regimes, favor movement)', () => { - expect(shouldRejectToken(makeToken({ iat: 100 }), makeToken({ oiat: 100 }))).toBe(false); + it('rejects when oiat equal and incoming iat missing (treats missing as 0, older)', () => { + expect(shouldRejectToken(makeToken({ oiat: 100, iat: 150 }), makeToken({ oiat: 100 }))).toBe(true); }); }); - describe('neither has oiat', () => { - it('row 12: rejects when existing iat > incoming iat', () => { - expect(shouldRejectToken(makeToken({ iat: 200 }), makeToken({ iat: 150 }))).toBe(true); - }); - - it('row 13: accepts when existing iat < incoming iat', () => { - expect(shouldRejectToken(makeToken({ iat: 150 }), makeToken({ iat: 200 }))).toBe(false); + describe('legacy (missing oiat) safety net', () => { + // Origin emits oiat for all mints post-rollout. These cases protect against + // a stale pre-rollout token lingering in a cookie or cache after upgrade. + it('rejects an incoming legacy token (no oiat) when existing has oiat', () => { + expect(shouldRejectToken(makeToken({ oiat: 100 }), makeToken({ iat: 9999 }))).toBe(true); }); - it('row 14: rejects when iat equal (keep existing, avoid churn)', () => { - expect(shouldRejectToken(makeToken({ iat: 150 }), makeToken({ iat: 150 }))).toBe(true); + it('accepts an incoming oiat-bearing token when existing is legacy (no oiat)', () => { + expect(shouldRejectToken(makeToken({ iat: 9999 }), makeToken({ oiat: 100 }))).toBe(false); }); - it("row 15: accepts when both iat null (can't compare, accept)", () => { - expect(shouldRejectToken(makeToken(), makeToken())).toBe(false); + it('accepts incoming when both sides are legacy (cannot rank, safe default)', () => { + expect(shouldRejectToken(makeToken({ iat: 200 }), makeToken({ iat: 100 }))).toBe(false); }); }); describe('JWT input (cookie path)', () => { - it('accepts a raw JWT for both arguments', () => { + it('accepts raw decoded JWT for both arguments', () => { expect(shouldRejectToken(makeJwt({ oiat: 100 }), makeJwt({ oiat: 200 }))).toBe(false); expect(shouldRejectToken(makeJwt({ oiat: 200 }), makeJwt({ oiat: 100 }))).toBe(true); }); @@ -117,19 +79,4 @@ describe('shouldRejectToken', () => { expect(shouldRejectToken(makeJwt({ oiat: 100, iat: 200 }), makeJwt({ oiat: 100, iat: 150 }))).toBe(true); }); }); - - describe('legacy and edge cases', () => { - // oiat is set by origin only; legacy tokens minted before the feature flag - // landed will have no oiat field. A token with oiat literally === 0 is also - // possible if a clock test mints during epoch (or if origin malfunctions). - it('treats oiat: 0 as PRESENT (numeric zero, not missing)', () => { - // claimFreshness uses ?? so 0 is a valid oiat. Tie-break logic checks - // `oiat != null`, which treats 0 as present. - expect(claimFreshness(makeJwt({ oiat: 0, iat: 100 }))).toBe(0); - }); - - it('legacy (no oiat) older token loses to newer token', () => { - expect(shouldRejectToken(makeJwt({ iat: 100 }), makeJwt({ iat: 200 }))).toBe(false); - }); - }); }); diff --git a/packages/clerk-js/src/core/tokenFreshness.ts b/packages/clerk-js/src/core/tokenFreshness.ts index 2e2b0336150..e1cac17e20d 100644 --- a/packages/clerk-js/src/core/tokenFreshness.ts +++ b/packages/clerk-js/src/core/tokenFreshness.ts @@ -1,27 +1,5 @@ import type { JWT, TokenResource } from '@clerk/shared/types'; -/** - * Returns the claim freshness of a token or raw JWT. - * - * - If the token has `oiat` (JWT header): that's when claims were last assembled from the DB. - * Edge re-mints copy this value forward, so iat can be recent while oiat is old. - * - If the token has no `oiat`: it's origin-minted (coupled FF means no Session Minter), - * so iat IS when claims were last read from the DB. - * - * @internal - */ -export function claimFreshness(input: TokenResource | JWT | undefined | null): number | undefined { - if (!input) { - return undefined; - } - // TokenResource has .jwt wrapping the JWT; raw JWT has .header directly - const jwt = 'getRawString' in input ? input.jwt : input; - return jwt?.header?.oiat ?? jwt?.claims?.iat; -} - -/** - * Extracts the underlying JWT from a TokenResource wrapper, or returns the JWT as-is. - */ function asJwt(input: TokenResource | JWT): JWT | undefined { return 'getRawString' in input ? input.jwt : input; } @@ -31,10 +9,16 @@ function asJwt(input: TokenResource | JWT): JWT | undefined { * Returns true if the incoming token is staler than the existing one. Accepts either * TokenResource (cache layer) or a raw decoded JWT (cookie layer). * - * Notes on coverage: enforced at /tokens responses, broadcast events, and cookie writes. - * Handshake-installed __session cookies are intentionally NOT gated here: handshake is - * a redirect-based full auth state resync, the browser commits the Set-Cookie before - * any SDK code runs, and there is no in-flight race window for the gate to protect. + * All origin-minted tokens now carry the `oiat` JWT header (origin-issued-at; the + * timestamp when token claims were last assembled from the DB). A token without + * `oiat` is from a pre-feature codebase and is by definition staler than any + * token that has one. + * + * Coverage: enforced at /tokens responses, broadcast events, and cookie writes. + * Handshake-installed __session cookies are intentionally NOT gated here: + * handshake is a redirect-based full auth state resync, the browser commits the + * Set-Cookie before any SDK code runs, and there is no in-flight race window + * for the gate to protect. * * @internal */ @@ -42,38 +26,30 @@ export function shouldRejectToken( existing: TokenResource | JWT, incoming: TokenResource | JWT, ): boolean { - const existingFreshness = claimFreshness(existing); - const incomingFreshness = claimFreshness(incoming); + const existingOiat = asJwt(existing)?.header?.oiat; + const incomingOiat = asJwt(incoming)?.header?.oiat; - // Can't determine freshness: accept incoming as safe default - if (existingFreshness == null || incomingFreshness == null) { + // Missing oiat = pre-feature stale token. The oiat-bearing side always wins. + if (incomingOiat == null && existingOiat == null) { return false; } - - // Different freshness: the fresher token wins - if (existingFreshness > incomingFreshness) { + if (incomingOiat == null) { return true; } - if (existingFreshness < incomingFreshness) { + if (existingOiat == null) { return false; } - // Equal freshness: tie-break depends on regime - const existingJwt = asJwt(existing); - const incomingJwt = asJwt(incoming); - const existingHasOiat = existingJwt?.header?.oiat != null; - const incomingHasOiat = incomingJwt?.header?.oiat != null; - const sameRegime = existingHasOiat === incomingHasOiat; - - if (sameRegime) { - // Same regime, equal freshness. - // Both have oiat: tie-break by iat (more recent mint wins). Equal iat: keep existing. - // Neither has oiat: both origin, same DB snapshot. Keep existing (avoid churn). - const existingIat = existingJwt?.claims?.iat ?? 0; - const incomingIat = incomingJwt?.claims?.iat ?? 0; - return existingIat >= incomingIat; + if (existingOiat > incomingOiat) { + return true; + } + if (existingOiat < incomingOiat) { + return false; } - // Different regimes, equal freshness. Transition is happening. Favor incoming. - return false; + // Equal oiat: tie-break by iat (more recent mint wins). Equal iat: keep + // existing (identical, no reason to replace). + const existingIat = asJwt(existing)?.claims?.iat ?? 0; + const incomingIat = asJwt(incoming)?.claims?.iat ?? 0; + return existingIat >= incomingIat; } From be30dfd0c42ff99be4e8b325add0e1c8bf224885 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 13 May 2026 15:47:38 +0300 Subject: [PATCH 08/20] chore(clerk-js): drop redundant comment in shouldRejectToken The doc comment above already explains the missing-oiat semantics; the inline comment was restating it. --- packages/clerk-js/src/core/tokenFreshness.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/clerk-js/src/core/tokenFreshness.ts b/packages/clerk-js/src/core/tokenFreshness.ts index e1cac17e20d..9571e1b0890 100644 --- a/packages/clerk-js/src/core/tokenFreshness.ts +++ b/packages/clerk-js/src/core/tokenFreshness.ts @@ -29,7 +29,6 @@ export function shouldRejectToken( const existingOiat = asJwt(existing)?.header?.oiat; const incomingOiat = asJwt(incoming)?.header?.oiat; - // Missing oiat = pre-feature stale token. The oiat-bearing side always wins. if (incomingOiat == null && existingOiat == null) { return false; } From d5f66c594db648a83b4630963fc5f781625fc03a Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 13 May 2026 16:31:33 +0300 Subject: [PATCH 09/20] test(clerk-js): update broadcast monotonicity test to use oiat tokens After the shouldRejectToken simplification, both-no-oiat tokens accept incoming (legacy safety-net path). The existing broadcast monotonicity test used no-oiat JWTs and asserted the old iat-only behavior, which would fail under the new B3 branch. Update the test to use oiat-bearing JWTs so it exercises the real production case (A1: ex.oiat > in.oiat -> reject incoming). Adds a createJwtWithOiat helper next to the existing createJwtWithTtl helper. --- .../src/core/__tests__/tokenCache.test.ts | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index 7b67e636bca..722b8b02615 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -31,6 +31,17 @@ function createJwtWithTtl(iatSeconds: number, ttlSeconds: number): string { return `${headerB64}.${payloadB64}.${signature}`; } +/** + * Helper to create a JWT with custom iat AND oiat header for monotonic-freshness tests + */ +function createJwtWithOiat(iatSeconds: number, oiatSeconds: number, ttlSeconds = 60): string { + const header = { alg: 'HS256', typ: 'JWT', oiat: oiatSeconds }; + const payload = { sid: 'session_123', exp: iatSeconds + ttlSeconds, iat: iatSeconds }; + const b64 = (o: object) => + btoa(JSON.stringify(o)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + return `${b64(header)}.${b64(payload)}.test-signature`; +} + describe('SessionTokenCache', () => { let mockBroadcastChannel: { addEventListener: ReturnType; @@ -194,13 +205,18 @@ describe('SessionTokenCache', () => { }); it('enforces monotonicity: does not overwrite newer token with older one', () => { + // Both tokens carry oiat (the production case post-rollout). Older oiat + // broadcast must not clobber the newer one already in cache. + const newerJwt = createJwtWithOiat(1666648250, 1666648250); + const olderJwt = createJwtWithOiat(1666648190, 1666648190); + const newerEvent: MessageEvent = { data: { organizationId: null, sessionId: 'session_123', template: undefined, tokenId: 'session_123', - tokenRaw: mockJwt, + tokenRaw: newerJwt, traceId: 'test_trace_7', }, } as MessageEvent; @@ -210,9 +226,6 @@ describe('SessionTokenCache', () => { expect(resultAfterNewer).toBeDefined(); const newerCreatedAt = resultAfterNewer?.entry.createdAt; - // mockJwt has iat: 1666648250, so create an older one with iat: 1666648190 (60 seconds earlier) - const olderJwt = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NjY2NDg4NTAsImlhdCI6MTY2NjY0ODE5MH0.Z1BC47lImYvaAtluJlY-kBo0qOoAk42Xb-gNrB2SxJg'; const olderEvent: MessageEvent = { data: { organizationId: null, From 58ee789943328b43da9c8a86a64d79f643de334b Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 13 May 2026 17:03:49 +0300 Subject: [PATCH 10/20] refactor(clerk-js): rename shouldRejectToken -> pickFreshestJwt The new function returns the freshest of the two arguments rather than a reject/accept boolean. The semantic is positive ("pick the fresher") and the callsite uses identity comparison to detect the no-update case. Callsites updated: - tokenCache.ts broadcast handler: if (pickFreshestJwt(existing, in) === existing) skip - Session.ts cached emit: same identity check - Session.ts dispatch: same identity check - AuthCookieService.ts cookie write: same identity check Tests rewritten to assert .toBe() rather than booleans. --- .../src/core/__tests__/tokenFreshness.test.ts | 77 +++++++++++-------- .../src/core/auth/AuthCookieService.ts | 6 +- .../clerk-js/src/core/resources/Session.ts | 13 ++-- packages/clerk-js/src/core/tokenCache.ts | 4 +- packages/clerk-js/src/core/tokenFreshness.ts | 40 ++++------ 5 files changed, 73 insertions(+), 67 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts b/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts index ade537f82ff..2a79f7789dc 100644 --- a/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts @@ -1,7 +1,7 @@ import type { JWT, TokenResource } from '@clerk/shared/types'; import { describe, expect, it } from 'vitest'; -import { shouldRejectToken } from '../tokenFreshness'; +import { pickFreshestJwt } from '../tokenFreshness'; function makeToken(opts: { oiat?: number; iat?: number } = {}): TokenResource { return { @@ -20,63 +20,78 @@ function makeJwt(opts: { oiat?: number; iat?: number } = {}): JWT { } as unknown as JWT; } -describe('shouldRejectToken', () => { +describe('pickFreshestJwt', () => { describe('both have oiat (the only reachable path post-rollout)', () => { - it('rejects when existing oiat > incoming oiat', () => { - expect(shouldRejectToken(makeToken({ oiat: 100 }), makeToken({ oiat: 90 }))).toBe(true); + it('picks existing when existing oiat > incoming oiat', () => { + const existing = makeToken({ oiat: 100 }); + const incoming = makeToken({ oiat: 90 }); + expect(pickFreshestJwt(existing, incoming)).toBe(existing); }); - it('accepts when existing oiat < incoming oiat', () => { - expect(shouldRejectToken(makeToken({ oiat: 90 }), makeToken({ oiat: 100 }))).toBe(false); + it('picks incoming when existing oiat < incoming oiat', () => { + const existing = makeToken({ oiat: 90 }); + const incoming = makeToken({ oiat: 100 }); + expect(pickFreshestJwt(existing, incoming)).toBe(incoming); }); - it('rejects when oiat equal and existing iat > incoming iat', () => { - expect(shouldRejectToken(makeToken({ oiat: 100, iat: 200 }), makeToken({ oiat: 100, iat: 150 }))).toBe(true); + it('picks existing when oiat equal and existing iat > incoming iat', () => { + const existing = makeToken({ oiat: 100, iat: 200 }); + const incoming = makeToken({ oiat: 100, iat: 150 }); + expect(pickFreshestJwt(existing, incoming)).toBe(existing); }); - it('accepts when oiat equal and existing iat < incoming iat', () => { - expect(shouldRejectToken(makeToken({ oiat: 100, iat: 150 }), makeToken({ oiat: 100, iat: 200 }))).toBe(false); + it('picks incoming when oiat equal and existing iat < incoming iat', () => { + const existing = makeToken({ oiat: 100, iat: 150 }); + const incoming = makeToken({ oiat: 100, iat: 200 }); + expect(pickFreshestJwt(existing, incoming)).toBe(incoming); }); - it('rejects when oiat equal and iat equal (identical, keep existing)', () => { - expect(shouldRejectToken(makeToken({ oiat: 100, iat: 150 }), makeToken({ oiat: 100, iat: 150 }))).toBe(true); + it('picks existing when oiat equal and iat equal (identical, no churn)', () => { + const existing = makeToken({ oiat: 100, iat: 150 }); + const incoming = makeToken({ oiat: 100, iat: 150 }); + expect(pickFreshestJwt(existing, incoming)).toBe(existing); }); - it('rejects when oiat equal and incoming iat missing (treats missing as 0, older)', () => { - expect(shouldRejectToken(makeToken({ oiat: 100, iat: 150 }), makeToken({ oiat: 100 }))).toBe(true); + it('picks existing when oiat equal and incoming iat missing (treated as 0)', () => { + const existing = makeToken({ oiat: 100, iat: 150 }); + const incoming = makeToken({ oiat: 100 }); + expect(pickFreshestJwt(existing, incoming)).toBe(existing); }); }); describe('legacy (missing oiat) safety net', () => { - // Origin emits oiat for all mints post-rollout. These cases protect against - // a stale pre-rollout token lingering in a cookie or cache after upgrade. - it('rejects an incoming legacy token (no oiat) when existing has oiat', () => { - expect(shouldRejectToken(makeToken({ oiat: 100 }), makeToken({ iat: 9999 }))).toBe(true); + it('picks existing when incoming is legacy (no oiat) and existing has oiat', () => { + const existing = makeToken({ oiat: 100 }); + const incoming = makeToken({ iat: 9999 }); + expect(pickFreshestJwt(existing, incoming)).toBe(existing); }); - it('accepts an incoming oiat-bearing token when existing is legacy (no oiat)', () => { - expect(shouldRejectToken(makeToken({ iat: 9999 }), makeToken({ oiat: 100 }))).toBe(false); + it('picks incoming when existing is legacy and incoming has oiat', () => { + const existing = makeToken({ iat: 9999 }); + const incoming = makeToken({ oiat: 100 }); + expect(pickFreshestJwt(existing, incoming)).toBe(incoming); }); - it('accepts incoming when both sides are legacy (cannot rank, safe default)', () => { - expect(shouldRejectToken(makeToken({ iat: 200 }), makeToken({ iat: 100 }))).toBe(false); + it('picks incoming when both sides are legacy (cannot rank, safe default)', () => { + const existing = makeToken({ iat: 200 }); + const incoming = makeToken({ iat: 100 }); + expect(pickFreshestJwt(existing, incoming)).toBe(incoming); }); }); describe('JWT input (cookie path)', () => { it('accepts raw decoded JWT for both arguments', () => { - expect(shouldRejectToken(makeJwt({ oiat: 100 }), makeJwt({ oiat: 200 }))).toBe(false); - expect(shouldRejectToken(makeJwt({ oiat: 200 }), makeJwt({ oiat: 100 }))).toBe(true); - }); - - it('mixes TokenResource and raw JWT inputs', () => { - expect(shouldRejectToken(makeToken({ oiat: 100 }), makeJwt({ oiat: 200 }))).toBe(false); - expect(shouldRejectToken(makeJwt({ oiat: 200 }), makeToken({ oiat: 100 }))).toBe(true); + const a = makeJwt({ oiat: 100 }); + const b = makeJwt({ oiat: 200 }); + expect(pickFreshestJwt(a, b)).toBe(b); + expect(pickFreshestJwt(b, a)).toBe(b); }); it('tie-breaks by iat on equal oiat for raw JWT inputs', () => { - expect(shouldRejectToken(makeJwt({ oiat: 100, iat: 150 }), makeJwt({ oiat: 100, iat: 200 }))).toBe(false); - expect(shouldRejectToken(makeJwt({ oiat: 100, iat: 200 }), makeJwt({ oiat: 100, iat: 150 }))).toBe(true); + const a = makeJwt({ oiat: 100, iat: 150 }); + const b = makeJwt({ oiat: 100, iat: 200 }); + expect(pickFreshestJwt(a, b)).toBe(b); + expect(pickFreshestJwt(b, a)).toBe(b); }); }); }); diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index 6050d135f3c..eca79b57842 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -15,7 +15,7 @@ import { noop } from '@clerk/shared/utils'; import { debugLogger } from '@/utils/debug'; import { decode } from '@/utils/jwt'; -import { shouldRejectToken } from '../tokenFreshness'; +import { pickFreshestJwt } from '../tokenFreshness'; import { clerkMissingDevBrowser } from '../errors'; import { eventBus, events } from '../events'; import type { FapiClient } from '../fapiClient'; @@ -197,8 +197,6 @@ export class AuthCookieService { } // Monotonic freshness guard: don't regress the cookie within the same session. - // Uses shouldRejectToken so equal-oiat tokens still tie-break by iat (newer wins), - // matching the comparator used at the broadcast handler and Session resource. if (token) { const currentRaw = this.sessionCookie.get(); if (currentRaw) { @@ -209,7 +207,7 @@ export class AuthCookieService { const incomingSid = incoming.claims.sid; // Only apply within the same session. Different sessions always allowed. if (currentSid && incomingSid && currentSid === incomingSid) { - if (shouldRejectToken(current, incoming)) { + if (pickFreshestJwt(current, incoming) === current) { return; } } diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 7b4ff365151..1465413757b 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -42,7 +42,7 @@ import type { } from '@clerk/shared/types'; import { isWebAuthnSupported as isWebAuthnSupportedOnWindow } from '@clerk/shared/webauthn'; -import { shouldRejectToken } from '@/core/tokenFreshness'; +import { pickFreshestJwt } from '@/core/tokenFreshness'; import { unixEpochToDate } from '@/utils/date'; import { debugLogger } from '@/utils/debug'; import { TokenId } from '@/utils/tokenId'; @@ -459,8 +459,9 @@ export class Session extends BaseResource implements SessionResource { // 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()) { - const reject = this.lastActiveToken && shouldRejectToken(this.lastActiveToken, cachedToken); - if (!reject) { + const isStaler = + this.lastActiveToken && pickFreshestJwt(this.lastActiveToken, cachedToken) === this.lastActiveToken; + if (!isStaler) { eventBus.emit(events.TokenUpdate, { token: cachedToken }); } } @@ -522,10 +523,8 @@ export class Session extends BaseResource implements SessionResource { return; } - if (this.lastActiveToken) { - if (shouldRejectToken(this.lastActiveToken, token)) { - return; - } + if (this.lastActiveToken && pickFreshestJwt(this.lastActiveToken, token) === this.lastActiveToken) { + return; } eventBus.emit(events.TokenUpdate, { token }); diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index c4d1b8c268e..dd00df3dc64 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -5,7 +5,7 @@ import { TokenId } from '@/utils/tokenId'; import { POLLER_INTERVAL_IN_MS } from './auth/SessionCookiePoller'; import { Token } from './resources/internal'; -import { shouldRejectToken } from './tokenFreshness'; +import { pickFreshestJwt } from './tokenFreshness'; /** * Identifies a cached token entry by tokenId and optional audience. @@ -289,7 +289,7 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const result = get({ tokenId: data.tokenId }); if (result) { const existingToken = await result.entry.tokenResolver; - if (shouldRejectToken(existingToken, token)) { + if (pickFreshestJwt(existingToken, token) === existingToken) { debugLogger.debug( 'Ignoring staler token broadcast', { tokenId: data.tokenId, traceId: data.traceId }, diff --git a/packages/clerk-js/src/core/tokenFreshness.ts b/packages/clerk-js/src/core/tokenFreshness.ts index 9571e1b0890..f37fc1956fd 100644 --- a/packages/clerk-js/src/core/tokenFreshness.ts +++ b/packages/clerk-js/src/core/tokenFreshness.ts @@ -5,50 +5,44 @@ function asJwt(input: TokenResource | JWT): JWT | undefined { } /** - * Determines whether an incoming token should be rejected in favor of the existing one. - * Returns true if the incoming token is staler than the existing one. Accepts either - * TokenResource (cache layer) or a raw decoded JWT (cookie layer). + * Picks the freshest of two tokens. Returns whichever argument has the more + * recent claim freshness; on a tie, returns `existing` (no churn). * - * All origin-minted tokens now carry the `oiat` JWT header (origin-issued-at; the - * timestamp when token claims were last assembled from the DB). A token without + * All origin-minted tokens carry the `oiat` JWT header (origin-issued-at; + * timestamp when claims were last assembled from the DB). A token without * `oiat` is from a pre-feature codebase and is by definition staler than any * token that has one. * - * Coverage: enforced at /tokens responses, broadcast events, and cookie writes. - * Handshake-installed __session cookies are intentionally NOT gated here: - * handshake is a redirect-based full auth state resync, the browser commits the - * Set-Cookie before any SDK code runs, and there is no in-flight race window - * for the gate to protect. + * Coverage: invoked at /tokens responses, broadcast events, and cookie writes. + * Handshake-installed __session cookies are intentionally NOT gated: + * handshake is a redirect-based full auth state resync, the browser commits + * the Set-Cookie before any SDK code runs, and there is no in-flight race + * window for the gate to protect. * * @internal */ -export function shouldRejectToken( - existing: TokenResource | JWT, - incoming: TokenResource | JWT, -): boolean { +export function pickFreshestJwt(existing: T, incoming: T): T { const existingOiat = asJwt(existing)?.header?.oiat; const incomingOiat = asJwt(incoming)?.header?.oiat; - if (incomingOiat == null && existingOiat == null) { - return false; + if (existingOiat == null && incomingOiat == null) { + return incoming; } if (incomingOiat == null) { - return true; + return existing; } if (existingOiat == null) { - return false; + return incoming; } if (existingOiat > incomingOiat) { - return true; + return existing; } if (existingOiat < incomingOiat) { - return false; + return incoming; } - // Equal oiat: tie-break by iat (more recent mint wins). Equal iat: keep - // existing (identical, no reason to replace). const existingIat = asJwt(existing)?.claims?.iat ?? 0; const incomingIat = asJwt(incoming)?.claims?.iat ?? 0; - return existingIat >= incomingIat; + return existingIat >= incomingIat ? existing : incoming; } From 00fa5c73da9d403893df6f0a86e816a35be54bd4 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 13 May 2026 17:17:26 +0300 Subject: [PATCH 11/20] test(clerk-js): document same-object behavior in pickFreshestJwt Cache hydration can return the same object that's already stored as lastActiveToken. The =-=-= identity check at Session.ts:463 treats this as 'existing won' and suppresses the redundant TokenUpdate emit. That's intentional. Adds a test asserting the same-reference semantic. --- .../src/core/__tests__/tokenFreshness.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts b/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts index 2a79f7789dc..a3509dba3b3 100644 --- a/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts @@ -79,6 +79,17 @@ describe('pickFreshestJwt', () => { }); }); + describe('same object reference', () => { + // When the cache hands back the same object that is already stored as + // lastActiveToken, callers use `pickFreshestJwt(a, b) === a` to detect + // "existing won, suppress redundant emit". This test documents that + // intentional behavior. + it('returns the same reference when both args are the same object', () => { + const token = makeToken({ oiat: 100, iat: 150 }); + expect(pickFreshestJwt(token, token)).toBe(token); + }); + }); + describe('JWT input (cookie path)', () => { it('accepts raw decoded JWT for both arguments', () => { const a = makeJwt({ oiat: 100 }); From c22e89827c5a4aac2836657cb662f1202cbf6396 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 13 May 2026 17:24:33 +0300 Subject: [PATCH 12/20] style(clerk-js): format tokenCache.test.ts per prettier Wrap the b64 arrow expression in createJwtWithOiat over two lines to match the project's prettier printWidth. CI flagged the original one-liner as a format violation. --- packages/clerk-js/src/core/__tests__/tokenCache.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index 722b8b02615..0d82152f0a0 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -37,8 +37,7 @@ function createJwtWithTtl(iatSeconds: number, ttlSeconds: number): string { function createJwtWithOiat(iatSeconds: number, oiatSeconds: number, ttlSeconds = 60): string { const header = { alg: 'HS256', typ: 'JWT', oiat: oiatSeconds }; const payload = { sid: 'session_123', exp: iatSeconds + ttlSeconds, iat: iatSeconds }; - const b64 = (o: object) => - btoa(JSON.stringify(o)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + const b64 = (o: object) => btoa(JSON.stringify(o)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); return `${b64(header)}.${b64(payload)}.test-signature`; } From d2650581544385f6c52d8e3c6de677b488e00649 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 13 May 2026 17:38:16 +0300 Subject: [PATCH 13/20] style(clerk-js): fix import sort order in AuthCookieService ESLint simple-import-sort/imports flagged pickFreshestJwt as misplaced. Move it to the correct alphabetical slot (after resources/Environment, before ./cookies/...). --- packages/clerk-js/src/core/auth/AuthCookieService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index eca79b57842..aaa06095e3a 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -15,11 +15,11 @@ import { noop } from '@clerk/shared/utils'; import { debugLogger } from '@/utils/debug'; import { decode } from '@/utils/jwt'; -import { pickFreshestJwt } from '../tokenFreshness'; import { clerkMissingDevBrowser } from '../errors'; import { eventBus, events } from '../events'; import type { FapiClient } from '../fapiClient'; import { Environment } from '../resources/Environment'; +import { pickFreshestJwt } from '../tokenFreshness'; import { createActiveContextCookie } from './cookies/activeContext'; import type { ClientUatCookieHandler } from './cookies/clientUat'; import { createClientUatCookie } from './cookies/clientUat'; From 78b3328e2a09abde503cb0a20856a6d011ad2b9b Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 13 May 2026 17:53:52 +0300 Subject: [PATCH 14/20] revert(clerk-js): remove cookie write monotonic guard The cookie write guard at AuthCookieService.updateSessionCookie was causing integration test failures across handshake, sessions, and multiple framework matrices. The guard would reject token writes when oiat+iat matched, but two tokens with identical timestamps can still differ in OTHER claims (azp added in a recent token-format rollout, org_id, etc.). Backend then logged 'Session token from cookie is missing the azp claim' and treated the session as invalid, redirecting to /sign-in. The broadcast handler (tokenCache.ts:292) and Session resource (Session.ts:463, :526) keep the monotonic enforcement at the layers where it works correctly. The cookie chokepoint was too aggressive. The cookie path deserves a guard but with a different shape (e.g., raw-string equality or signature compare), not the claim-timestamp shape. --- .../src/core/auth/AuthCookieService.ts | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index aaa06095e3a..6ccd2967b10 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -13,13 +13,11 @@ import type { Clerk, InstanceType } from '@clerk/shared/types'; import { noop } from '@clerk/shared/utils'; import { debugLogger } from '@/utils/debug'; -import { decode } from '@/utils/jwt'; import { clerkMissingDevBrowser } from '../errors'; import { eventBus, events } from '../events'; import type { FapiClient } from '../fapiClient'; import { Environment } from '../resources/Environment'; -import { pickFreshestJwt } from '../tokenFreshness'; import { createActiveContextCookie } from './cookies/activeContext'; import type { ClientUatCookieHandler } from './cookies/clientUat'; import { createClientUatCookie } from './cookies/clientUat'; @@ -196,27 +194,6 @@ export class AuthCookieService { return; } - // Monotonic freshness guard: don't regress the cookie within the same session. - if (token) { - const currentRaw = this.sessionCookie.get(); - if (currentRaw) { - try { - const current = decode(currentRaw); - const incoming = decode(token); - const currentSid = current.claims.sid; - const incomingSid = incoming.claims.sid; - // Only apply within the same session. Different sessions always allowed. - if (currentSid && incomingSid && currentSid === incomingSid) { - if (pickFreshestJwt(current, incoming) === current) { - return; - } - } - } catch { - // If decode fails, allow the write (don't block on malformed tokens) - } - } - } - if (!token && !isValidBrowserOnline()) { debugLogger.warn('Removing session cookie (offline)', { sessionId: this.clerk.session?.id }, 'authCookieService'); } From 7efa1afc173b1f078154ca3e3e62c1e321a67aa7 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 13 May 2026 18:21:45 +0300 Subject: [PATCH 15/20] fix(clerk-js): pickFreshestJwt returns incoming on full tie Two tokens with identical oiat+iat may still differ in other claims (azp, org_id, etc.) added in a token-format rollout. The previous 'no churn on tie' rule suppressed legitimate updates and caused the backend to read stale claim sets, redirecting to /sign-in. Only suppress when existing is strictly fresher. --- .../clerk-js/src/core/__tests__/tokenFreshness.test.ts | 7 +++++-- packages/clerk-js/src/core/tokenFreshness.ts | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts b/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts index a3509dba3b3..1c7c5c38ecc 100644 --- a/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts @@ -46,10 +46,13 @@ describe('pickFreshestJwt', () => { expect(pickFreshestJwt(existing, incoming)).toBe(incoming); }); - it('picks existing when oiat equal and iat equal (identical, no churn)', () => { + it('picks incoming when oiat equal and iat equal (other claims may differ)', () => { + // Two tokens with identical oiat+iat may still differ in other claims + // (azp, org_id, etc.) during a token-format rollout. Only suppress when + // existing is strictly fresher; on full ties, let incoming through. const existing = makeToken({ oiat: 100, iat: 150 }); const incoming = makeToken({ oiat: 100, iat: 150 }); - expect(pickFreshestJwt(existing, incoming)).toBe(existing); + expect(pickFreshestJwt(existing, incoming)).toBe(incoming); }); it('picks existing when oiat equal and incoming iat missing (treated as 0)', () => { diff --git a/packages/clerk-js/src/core/tokenFreshness.ts b/packages/clerk-js/src/core/tokenFreshness.ts index f37fc1956fd..18a9bf59bf4 100644 --- a/packages/clerk-js/src/core/tokenFreshness.ts +++ b/packages/clerk-js/src/core/tokenFreshness.ts @@ -42,7 +42,11 @@ export function pickFreshestJwt(existing: T, inco return incoming; } + // Equal oiat: tie-break by iat (more recent mint wins). On a full tie, + // return incoming: two tokens with identical oiat+iat may still differ + // in other claims (azp, org_id, etc.) added in a token-format rollout, + // so we only suppress when existing is strictly fresher. const existingIat = asJwt(existing)?.claims?.iat ?? 0; const incomingIat = asJwt(incoming)?.claims?.iat ?? 0; - return existingIat >= incomingIat ? existing : incoming; + return existingIat > incomingIat ? existing : incoming; } From 64ab4ae1539fb75c929c98e2b4c8d8dd85e8baa7 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 13 May 2026 18:42:52 +0300 Subject: [PATCH 16/20] revert(clerk-js): drop Session.ts monotonic guards The Session.ts guards at #_getToken cache-hit emit and #dispatchTokenEvents were suppressing token:update events that AuthCookieService needs to write the session cookie. Backend then saw an empty/stale cookie and treated the session as unauthenticated. Keep only the broadcast handler guard in tokenCache.ts, which covers the original motivation: cross-tab races where a background tab's stale edge-minted token can clobber a fresher DB-minted token via the BroadcastChannel. --- packages/clerk-js/src/core/resources/Session.ts | 11 +---------- .../src/core/resources/__tests__/Session.test.ts | 4 +--- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 1465413757b..068dfe1ea41 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -42,7 +42,6 @@ import type { } from '@clerk/shared/types'; import { isWebAuthnSupported as isWebAuthnSupportedOnWindow } from '@clerk/shared/webauthn'; -import { pickFreshestJwt } from '@/core/tokenFreshness'; import { unixEpochToDate } from '@/utils/date'; import { debugLogger } from '@/utils/debug'; import { TokenId } from '@/utils/tokenId'; @@ -459,11 +458,7 @@ export class Session extends BaseResource implements SessionResource { // 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()) { - const isStaler = - this.lastActiveToken && pickFreshestJwt(this.lastActiveToken, cachedToken) === this.lastActiveToken; - if (!isStaler) { - eventBus.emit(events.TokenUpdate, { token: cachedToken }); - } + eventBus.emit(events.TokenUpdate, { token: cachedToken }); } result = cachedToken.getRawString() || null; } else if (!isBrowserOnline()) { @@ -523,10 +518,6 @@ export class Session extends BaseResource implements SessionResource { return; } - if (this.lastActiveToken && pickFreshestJwt(this.lastActiveToken, token) === this.lastActiveToken) { - return; - } - eventBus.emit(events.TokenUpdate, { token }); if (token.jwt) { diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 37f98879ed4..aee7f42f614 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -98,9 +98,7 @@ describe('Session', () => { expect(BaseResource.clerk.getFapiClient().request).not.toHaveBeenCalled(); expect(token).toEqual(mockJwt); - // Cache hits with the same token as lastActiveToken suppress re-emission - // to avoid unnecessary cookie writes (monotonic freshness guard). - expect(dispatchSpy).toHaveBeenCalledTimes(0); + expect(dispatchSpy).toHaveBeenCalledTimes(2); }); it('returns same token without API call when Session is reconstructed', async () => { From 7161778e81d2cfde77cfe8584668261ee1b0599f Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 13 May 2026 21:49:12 +0300 Subject: [PATCH 17/20] test(clerk-js): broadcast handler accepts fresher-oiat token over older cached one Inverse of the existing 'older broadcast does not overwrite newer' test. Confirms the monotonic guard is direction-correct: a fresher oiat replaces an older cached entry rather than being suppressed. --- .../src/core/__tests__/tokenCache.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index 0d82152f0a0..a5dc31bb307 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -243,6 +243,47 @@ describe('SessionTokenCache', () => { expect(resultAfterOlder?.entry.createdAt).toBe(newerCreatedAt); }); + it('enforces monotonicity: replaces older cached token when a fresher-oiat broadcast arrives', () => { + // Inverse of the previous test: a fresher-oiat broadcast must overwrite + // an older-oiat token already in cache. + const olderJwt = createJwtWithOiat(1666648190, 1666648190); + const newerJwt = createJwtWithOiat(1666648250, 1666648250); + + const olderEvent: MessageEvent = { + data: { + organizationId: null, + sessionId: 'session_123', + template: undefined, + tokenId: 'session_123', + tokenRaw: olderJwt, + traceId: 'test_trace_older_first', + }, + } as MessageEvent; + + broadcastListener(olderEvent); + const resultAfterOlder = SessionTokenCache.get({ tokenId: 'session_123' }); + expect(resultAfterOlder).toBeDefined(); + const olderCreatedAt = resultAfterOlder?.entry.createdAt; + + const newerEvent: MessageEvent = { + data: { + organizationId: null, + sessionId: 'session_123', + template: undefined, + tokenId: 'session_123', + tokenRaw: newerJwt, + traceId: 'test_trace_newer_second', + }, + } as MessageEvent; + + broadcastListener(newerEvent); + + const resultAfterNewer = SessionTokenCache.get({ tokenId: 'session_123' }); + expect(resultAfterNewer).toBeDefined(); + expect(resultAfterNewer?.entry.createdAt).not.toBe(olderCreatedAt); + expect(resultAfterNewer?.entry.createdAt).toBe(1666648250); + }); + it('successfully updates cache with valid token', () => { const event: MessageEvent = { data: { From 46fbc01ba6fbb5bbbcab188780300f334e99093e Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 13 May 2026 22:38:03 +0300 Subject: [PATCH 18/20] test(clerk-js): await broadcast handler in monotonicity test The handler is async (awaits the existing tokenResolver), so reading the cache synchronously after broadcastListener() captures the pre-await state. Type the listener as returning void | Promise and await it so the second broadcast finishes processing before we assert the new createdAt. --- .../clerk-js/src/core/__tests__/tokenCache.test.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index a5dc31bb307..b9ceb97f941 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -47,7 +47,7 @@ describe('SessionTokenCache', () => { close: ReturnType; postMessage: ReturnType; }; - let broadcastListener: (e: MessageEvent) => void; + let broadcastListener: (e: MessageEvent) => void | Promise; let originalBroadcastChannel: any; beforeEach(() => { @@ -243,7 +243,7 @@ describe('SessionTokenCache', () => { expect(resultAfterOlder?.entry.createdAt).toBe(newerCreatedAt); }); - it('enforces monotonicity: replaces older cached token when a fresher-oiat broadcast arrives', () => { + it('enforces monotonicity: replaces older cached token when a fresher-oiat broadcast arrives', async () => { // Inverse of the previous test: a fresher-oiat broadcast must overwrite // an older-oiat token already in cache. const olderJwt = createJwtWithOiat(1666648190, 1666648190); @@ -260,10 +260,10 @@ describe('SessionTokenCache', () => { }, } as MessageEvent; - broadcastListener(olderEvent); + await broadcastListener(olderEvent); const resultAfterOlder = SessionTokenCache.get({ tokenId: 'session_123' }); expect(resultAfterOlder).toBeDefined(); - const olderCreatedAt = resultAfterOlder?.entry.createdAt; + expect(resultAfterOlder?.entry.createdAt).toBe(1666648190); const newerEvent: MessageEvent = { data: { @@ -276,11 +276,10 @@ describe('SessionTokenCache', () => { }, } as MessageEvent; - broadcastListener(newerEvent); + await broadcastListener(newerEvent); const resultAfterNewer = SessionTokenCache.get({ tokenId: 'session_123' }); expect(resultAfterNewer).toBeDefined(); - expect(resultAfterNewer?.entry.createdAt).not.toBe(olderCreatedAt); expect(resultAfterNewer?.entry.createdAt).toBe(1666648250); }); From 67d38b2a21b9f4ec7b786871208612ad29c90741 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Thu, 14 May 2026 08:55:02 +0300 Subject: [PATCH 19/20] docs(clerk-js): update pickFreshestJwt doc to match incoming-on-tie behavior Doc said 'on a tie, returns existing (no churn)' but the implementation returns incoming on full ties. Rewrite the doc to match: only suppress when existing is strictly fresher; on a tie, hand through to incoming since the two tokens may differ in claims that don't affect freshness. Also await the broadcast handler in the older-rejected monotonicity test so it doesn't pass vacuously when the async handler runs after the assertion. Same shape as the newer-replaces-older test. --- .../clerk-js/src/core/__tests__/tokenCache.test.ts | 6 +++--- packages/clerk-js/src/core/tokenFreshness.ts | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index b9ceb97f941..1a7aab2a645 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -203,7 +203,7 @@ describe('SessionTokenCache', () => { expect(SessionTokenCache.size()).toBe(0); }); - it('enforces monotonicity: does not overwrite newer token with older one', () => { + it('enforces monotonicity: does not overwrite newer token with older one', async () => { // Both tokens carry oiat (the production case post-rollout). Older oiat // broadcast must not clobber the newer one already in cache. const newerJwt = createJwtWithOiat(1666648250, 1666648250); @@ -220,7 +220,7 @@ describe('SessionTokenCache', () => { }, } as MessageEvent; - broadcastListener(newerEvent); + await broadcastListener(newerEvent); const resultAfterNewer = SessionTokenCache.get({ tokenId: 'session_123' }); expect(resultAfterNewer).toBeDefined(); const newerCreatedAt = resultAfterNewer?.entry.createdAt; @@ -236,7 +236,7 @@ describe('SessionTokenCache', () => { }, } as MessageEvent; - broadcastListener(olderEvent); + await broadcastListener(olderEvent); const resultAfterOlder = SessionTokenCache.get({ tokenId: 'session_123' }); expect(resultAfterOlder).toBeDefined(); diff --git a/packages/clerk-js/src/core/tokenFreshness.ts b/packages/clerk-js/src/core/tokenFreshness.ts index 18a9bf59bf4..9aa87fd9948 100644 --- a/packages/clerk-js/src/core/tokenFreshness.ts +++ b/packages/clerk-js/src/core/tokenFreshness.ts @@ -6,18 +6,18 @@ function asJwt(input: TokenResource | JWT): JWT | undefined { /** * Picks the freshest of two tokens. Returns whichever argument has the more - * recent claim freshness; on a tie, returns `existing` (no churn). + * recent claim freshness. On a full tie (same oiat AND same iat) it returns + * `incoming`, since two tokens with identical timestamps can still differ in + * other claims (azp, org_id, etc.) during a token-format rollout, so the + * guard should only suppress when existing is strictly fresher. * * All origin-minted tokens carry the `oiat` JWT header (origin-issued-at; * timestamp when claims were last assembled from the DB). A token without * `oiat` is from a pre-feature codebase and is by definition staler than any * token that has one. * - * Coverage: invoked at /tokens responses, broadcast events, and cookie writes. - * Handshake-installed __session cookies are intentionally NOT gated: - * handshake is a redirect-based full auth state resync, the browser commits - * the Set-Cookie before any SDK code runs, and there is no in-flight race - * window for the gate to protect. + * Used by the cross-tab broadcast handler in tokenCache to drop stale + * edge-minted tokens that would otherwise clobber a fresher cached entry. * * @internal */ From 20d47e68cc93de198825c1b828a6cd31493d1392 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Thu, 14 May 2026 16:30:13 +0300 Subject: [PATCH 20/20] test(clerk-js): use ttl=120 for monotonicity test tokens The cache.get path drops entries whose iat+ttl is past the current test clock (nowSec=1666648260, POLLER_INTERVAL=5s). With the default ttl=60, the older token (iat=1666648190, exp=1666648250) was 10s expired and got purged before the assertion could read it. Use ttl=120 so both older (exp=1666648310) and newer (exp=1666648370) stay valid against the fixed test clock. --- packages/clerk-js/src/core/__tests__/tokenCache.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index 1a7aab2a645..baad7691c7d 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -245,9 +245,11 @@ describe('SessionTokenCache', () => { it('enforces monotonicity: replaces older cached token when a fresher-oiat broadcast arrives', async () => { // Inverse of the previous test: a fresher-oiat broadcast must overwrite - // an older-oiat token already in cache. - const olderJwt = createJwtWithOiat(1666648190, 1666648190); - const newerJwt = createJwtWithOiat(1666648250, 1666648250); + // an older-oiat token already in cache. Use ttl=120 so both tokens stay + // valid against the test clock (nowSec=1666648260) — cache.get drops + // entries past their expiry. + const olderJwt = createJwtWithOiat(1666648190, 1666648190, 120); + const newerJwt = createJwtWithOiat(1666648250, 1666648250, 120); const olderEvent: MessageEvent = { data: {