From 6de17a5cd0b37fb92dd0c6f57c9b74505ed1c8bf Mon Sep 17 00:00:00 2001 From: Mike Wickett Date: Thu, 29 Oct 2026 19:02:36 -0400 Subject: [PATCH 1/2] fix(js): keep the Device Intelligence token per instance on a shared origin --- .changeset/protect-token-per-instance.md | 5 + .../src/core/__tests__/protect.test.ts | 27 ++-- .../protectChallengeLoadTimeout.test.ts | 11 +- .../src/core/__tests__/protectSession.test.ts | 152 ++++++++++++++---- packages/clerk-js/src/core/clerk.ts | 2 +- packages/clerk-js/src/core/protect.ts | 8 +- packages/clerk-js/src/core/protectSession.ts | 24 +-- 7 files changed, 168 insertions(+), 61 deletions(-) create mode 100644 .changeset/protect-token-per-instance.md diff --git a/.changeset/protect-token-per-instance.md b/.changeset/protect-token-per-instance.md new file mode 100644 index 00000000000..262c74fb00c --- /dev/null +++ b/.changeset/protect-token-per-instance.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Fix sign-ins and sign-ups being blocked by Device Intelligence checks when the same origin served more than one Clerk instance, such as an app moving from development to production keys. The Device Intelligence token is now stored per instance, so a token acquired for one instance is no longer sent to another. diff --git a/packages/clerk-js/src/core/__tests__/protect.test.ts b/packages/clerk-js/src/core/__tests__/protect.test.ts index 683c4420373..88275d89e08 100644 --- a/packages/clerk-js/src/core/__tests__/protect.test.ts +++ b/packages/clerk-js/src/core/__tests__/protect.test.ts @@ -5,6 +5,8 @@ import { Protect } from '../protect'; import { __internal_resetProtectStorage } from '../protectSession'; import type { Environment } from '../resources'; +const FRONTEND_API = 'clerk.example.com'; + const environment = (loaders: unknown[]): Environment => ({ protectConfig: { loaders } }) as unknown as Environment; /** @@ -61,7 +63,7 @@ afterEach(() => { describe('Protect.load', () => { it('does nothing without a protect config', () => { - new Protect().load(environment([])); + new Protect().load(environment([]), FRONTEND_API); expect(document.head.querySelector('script')).toBeNull(); expect(localStorage.getItem('__clerk_protect_pid')).toBeNull(); }); @@ -72,6 +74,7 @@ describe('Protect.load', () => { environment([ { target: 'head', type: 'script', attributes: { 'data-loader': 'https://loader.example.com/ins_2abc.js' } }, ]), + FRONTEND_API, ); expect(document.head.querySelector('script')?.getAttribute('data-loader')).toBe( @@ -96,6 +99,7 @@ describe('Protect.load', () => { }, }), ]), + FRONTEND_API, ); const element = await injected('script'); @@ -111,7 +115,7 @@ describe('Protect.load', () => { it('substitutes placeholders in textContent as well as attributes', async () => { const protect = new Protect(); - protect.load(environment([loader({ text_content: 'window.__vendor_cid = "{cid}";' })])); + protect.load(environment([loader({ text_content: 'window.__vendor_cid = "{cid}";' })]), FRONTEND_API); const element = await injected('script'); expect(element.textContent).toBe(`window.__vendor_cid = "${element.getAttribute('data-cid')}";`); @@ -120,14 +124,14 @@ describe('Protect.load', () => { it('never interpolates {instance_id}', async () => { const protect = new Protect(); - protect.load(environment([loader({ attributes: { 'data-src': '{instance_id}/{cid}.js' } })])); + protect.load(environment([loader({ attributes: { 'data-src': '{instance_id}/{cid}.js' } })]), FRONTEND_API); expect((await injected('script')).getAttribute('data-src')).toContain('{instance_id}'); }); it('attaches the token once it has been acquired', async () => { const protect = new Protect(); - protect.load(environment([loader()])); + protect.load(environment([loader()]), FRONTEND_API); serveInline(await injected('script')); @@ -139,7 +143,7 @@ describe('Protect.load', () => { it('still applies the other loaders when a token for this browser session is shared', async () => { localStorage.setItem( - '__clerk_protect_st', + `__clerk_protect_st:${FRONTEND_API}`, JSON.stringify({ token: 'v1.cached.mac', exp: nowSeconds() + 43_200, rid: 'b'.repeat(26), at: Date.now() }), ); @@ -149,6 +153,7 @@ describe('Protect.load', () => { loader({ attributes: { 'data-role': 'detection' } }), loader({ attributes: { 'data-cid': '{cid}', 'data-role': 'token' } }), ]), + FRONTEND_API, ); // Acquisition happens once per browser session, so the token loader is skipped… @@ -162,7 +167,7 @@ describe('Protect.load', () => { vi.spyOn(Math, 'random').mockReturnValue(0.9); const protect = new Protect(); - protect.load(environment([loader({ rollout: 0.1 })])); + protect.load(environment([loader({ rollout: 0.1 })]), FRONTEND_API); expect(document.head.querySelector('script')).toBeNull(); // Out of rollout means Protect is off for this browser, so there is nothing to report either. @@ -171,7 +176,7 @@ describe('Protect.load', () => { it('reports script_error when the loader element fails to load', async () => { const protect = new Protect(); - protect.load(environment([loader({ token_timeout_ms: 5_000 })])); + protect.load(environment([loader({ token_timeout_ms: 5_000 })]), FRONTEND_API); (await injected('script')).dispatchEvent(new Event('error')); @@ -181,14 +186,16 @@ describe('Protect.load', () => { it('drops a malformed loader entry without failing the rest of the load', async () => { const protect = new Protect(); - // The config is server-controlled and cached; a bad entry must not take Clerk.load() down. - expect(() => protect.load(environment([null, loader({ attributes: { 'data-role': 'good' } })]))).not.toThrow(); + // The config is server-controlled and cached; a bad entry must not take Clerk.load(, FRONTEND_API) down. + expect(() => + protect.load(environment([null, loader({ attributes: { 'data-role': 'good' } })]), FRONTEND_API), + ).not.toThrow(); expect(document.head.querySelector('[data-role="good"]')).not.toBeNull(); }); it('survives a loader config that is not an array of objects at all', () => { const protect = new Protect(); - expect(() => protect.load(environment(['nope', 42, undefined]))).not.toThrow(); + expect(() => protect.load(environment(['nope', 42, undefined]), FRONTEND_API)).not.toThrow(); }); }); diff --git a/packages/clerk-js/src/core/__tests__/protectChallengeLoadTimeout.test.ts b/packages/clerk-js/src/core/__tests__/protectChallengeLoadTimeout.test.ts index da8fd6170b9..99368e732c7 100644 --- a/packages/clerk-js/src/core/__tests__/protectChallengeLoadTimeout.test.ts +++ b/packages/clerk-js/src/core/__tests__/protectChallengeLoadTimeout.test.ts @@ -5,6 +5,8 @@ import { Protect } from '../protect'; import { __internal_resetProtectStorage } from '../protectSession'; import type { Environment } from '../resources'; +const FRONTEND_API = 'clerk.example.com'; + const environment = (loaders: unknown[]): Environment => ({ protectConfig: { loaders } }) as unknown as Environment; /** No `src`, so jsdom does not fetch a real URL and race the assertions here. */ @@ -24,14 +26,14 @@ describe('Protect.challengeLoadTimeoutMs', () => { it('is undefined when no loader asks for one, so the caller falls back', () => { const protect = new Protect(); - protect.load(environment([loader()])); + protect.load(environment([loader()]), FRONTEND_API); expect(protect.challengeLoadTimeoutMs).toBeUndefined(); }); it('reports the value from the loader this browser was assigned', () => { const protect = new Protect(); - protect.load(environment([loader({ challenge_load_timeout_ms: 25_000 })])); + protect.load(environment([loader({ challenge_load_timeout_ms: 25_000 })]), FRONTEND_API); expect(protect.challengeLoadTimeoutMs).toBe(25_000); }); @@ -49,6 +51,7 @@ describe('Protect.challengeLoadTimeoutMs', () => { // The incumbent, which this browser does get. loader({ challenge_load_timeout_ms: 45_000 }), ]), + FRONTEND_API, ); expect(protect.challengeLoadTimeoutMs).toBe(45_000); @@ -58,7 +61,7 @@ describe('Protect.challengeLoadTimeoutMs', () => { vi.spyOn(Math, 'random').mockReturnValue(0.01); const protect = new Protect(); - protect.load(environment([loader({ rollout: 0.1, challenge_load_timeout_ms: 25_000 }), loader()])); + protect.load(environment([loader({ rollout: 0.1, challenge_load_timeout_ms: 25_000 }), loader()]), FRONTEND_API); expect(protect.challengeLoadTimeoutMs).toBe(25_000); }); @@ -71,6 +74,7 @@ describe('Protect.challengeLoadTimeoutMs', () => { loader({ challenge_load_timeout_ms: Number.POSITIVE_INFINITY }), loader({ challenge_load_timeout_ms: 'soon' as unknown as number }), ]), + FRONTEND_API, ); expect(protect.challengeLoadTimeoutMs).toBeUndefined(); @@ -84,6 +88,7 @@ describe('Protect.challengeLoadTimeoutMs', () => { loader({ challenge_load_timeout_ms: 25_000 }), loader({ challenge_load_timeout_ms: 45_000 }), ]), + FRONTEND_API, ); expect(protect.challengeLoadTimeoutMs).toBe(25_000); diff --git a/packages/clerk-js/src/core/__tests__/protectSession.test.ts b/packages/clerk-js/src/core/__tests__/protectSession.test.ts index ca86a0cf43a..b6464f7b242 100644 --- a/packages/clerk-js/src/core/__tests__/protectSession.test.ts +++ b/packages/clerk-js/src/core/__tests__/protectSession.test.ts @@ -12,6 +12,8 @@ import { ProtectSession, } from '../protectSession'; +const FRONTEND_API = 'clerk.example.com'; +const TOKEN_KEY = `__clerk_protect_st:${FRONTEND_API}`; const LOADER_SRC = 'https://loader.example.com/ins_2abc/{cid}/loader.js'; /** @@ -70,9 +72,9 @@ const harness = () => { return { applyLoader, elements, injected }; }; -const session = (loaders: ProtectLoader[], tokensInvalidBefore?: number) => { +const session = (loaders: ProtectLoader[], tokensInvalidBefore?: number, frontendApi = FRONTEND_API) => { const h = harness(); - return { session: ProtectSession.create(loaders, h.applyLoader, tokensInvalidBefore), ...h }; + return { session: ProtectSession.create(loaders, h.applyLoader, frontendApi, tokensInvalidBefore), ...h }; }; /** What the server does: the script body assigns the global, then the element fires `load`. */ @@ -246,7 +248,7 @@ describe('ProtectSession inline token', () => { __clerk_protect_status: 'ok', __clerk_protect_cid: created?.placeholders().cid, }); - expect(JSON.parse(localStorage.getItem('__clerk_protect_st') as string)).toMatchObject({ + expect(JSON.parse(localStorage.getItem(TOKEN_KEY) as string)).toMatchObject({ token: 'v1.payload.mac', rid: created?.placeholders().rid, }); @@ -382,7 +384,7 @@ describe('ProtectSession inline token', () => { }); it('reuses a fresh token without injecting the loader at all', async () => { - localStorage.setItem('__clerk_protect_st', storedEntry()); + localStorage.setItem(TOKEN_KEY, storedEntry()); const { session: created, elements } = session([loader()]); expect(created?.hasFreshToken()).toBe(true); @@ -398,7 +400,7 @@ describe('ProtectSession inline token', () => { }); it('ignores a stored token that has expired', async () => { - localStorage.setItem('__clerk_protect_st', storedEntry({ token: 'v1.stale.mac', exp: nowSeconds() - 1 })); + localStorage.setItem(TOKEN_KEY, storedEntry({ token: 'v1.stale.mac', exp: nowSeconds() - 1 })); const { session: created, injected } = session([loader()]); expect(created?.hasFreshToken()).toBe(false); @@ -411,7 +413,7 @@ describe('ProtectSession inline token', () => { it('ignores a planted token claiming more life than we ever mint', async () => { localStorage.setItem( - '__clerk_protect_st', + TOKEN_KEY, storedEntry({ token: 'v1.planted.mac', exp: nowSeconds() + 10 * 365 * 24 * 60 * 60 }), ); @@ -426,7 +428,7 @@ describe('ProtectSession inline token', () => { }); it('ignores a planted value that could never have been a mint', async () => { - localStorage.setItem('__clerk_protect_st', storedEntry({ token: 'not-a-token' })); + localStorage.setItem(TOKEN_KEY, storedEntry({ token: 'not-a-token' })); const { session: created, injected } = session([loader()]); // Shape alone proves nothing — only the server can tell a mint from a well-formed forgery — @@ -442,10 +444,7 @@ describe('ProtectSession inline token', () => { it('ignores an entry older than any token we would ever be issued', async () => { // `exp` is server truth compared against this browser's clock, so a clock running slow keeps // a lapsed entry alive. Elapsed local time is measured on one clock, so it still catches it. - localStorage.setItem( - '__clerk_protect_st', - storedEntry({ at: Date.now() - 25 * 60 * 60 * 1_000, exp: nowSeconds() + 43_200 }), - ); + localStorage.setItem(TOKEN_KEY, storedEntry({ at: Date.now() - 25 * 60 * 60 * 1_000, exp: nowSeconds() + 43_200 })); const { session: created, injected } = session([loader()]); expect(created?.hasFreshToken()).toBe(false); @@ -457,7 +456,7 @@ describe('ProtectSession inline token', () => { }); it('ignores an entry stamped in the future, which means the clock moved backwards', () => { - localStorage.setItem('__clerk_protect_st', storedEntry({ at: Date.now() + 60 * 60 * 1_000 })); + localStorage.setItem(TOKEN_KEY, storedEntry({ at: Date.now() + 60 * 60 * 1_000 })); const { session: created } = session([loader()]); expect(created?.hasFreshToken()).toBe(false); @@ -466,7 +465,7 @@ describe('ProtectSession inline token', () => { it('does not send a token that would lapse in flight', async () => { // Inside the send margin: still unexpired, but not worth shipping — it could only fail // verification by the time it arrived. - localStorage.setItem('__clerk_protect_st', storedEntry({ exp: nowSeconds() + 2 })); + localStorage.setItem(TOKEN_KEY, storedEntry({ exp: nowSeconds() + 2 })); const { session: created } = session([loader()]); expect(created?.hasFreshToken()).toBe(false); @@ -476,7 +475,7 @@ describe('ProtectSession inline token', () => { }); it('re-acquires when the configured floor has moved past the stored one', async () => { - localStorage.setItem('__clerk_protect_st', storedEntry({ token: 'v1.revoked.mac', floor: 1_000 })); + localStorage.setItem(TOKEN_KEY, storedEntry({ token: 'v1.revoked.mac', floor: 1_000 })); // Raising tokens_invalid_before is the recovery lever: a bad mint or a rolled key leaves // browsers holding something the server will not accept, and without this they would keep @@ -491,14 +490,14 @@ describe('ProtectSession inline token', () => { }); it('keeps a token acquired at or above the configured floor', () => { - localStorage.setItem('__clerk_protect_st', storedEntry({ floor: 2_000 })); + localStorage.setItem(TOKEN_KEY, storedEntry({ floor: 2_000 })); expect(session([loader()], 2_000).session?.hasFreshToken()).toBe(true); }); it('keeps cached tokens for an instance that has never set a floor', () => { // An absent floor counts as zero on both sides, so introducing the mechanism must not make // every browser re-acquire at once. - localStorage.setItem('__clerk_protect_st', storedEntry()); + localStorage.setItem(TOKEN_KEY, storedEntry()); expect(session([loader()]).session?.hasFreshToken()).toBe(true); }); @@ -508,11 +507,11 @@ describe('ProtectSession inline token', () => { serveInline(await injected(), { cid: created?.placeholders().cid }); await created?.getRequestParams(); - expect(JSON.parse(localStorage.getItem('__clerk_protect_st') as string)).toMatchObject({ floor: 2_000 }); + expect(JSON.parse(localStorage.getItem(TOKEN_KEY) as string)).toMatchObject({ floor: 2_000 }); }); it('reuses a mint whose version this build predates', async () => { - localStorage.setItem('__clerk_protect_st', storedEntry({ token: 'v9.cached.mac' })); + localStorage.setItem(TOKEN_KEY, storedEntry({ token: 'v9.cached.mac' })); // The shape check must not pin a version. The server may mint ahead of this build, and // rejecting that here would re-run the loader on every page load until the SDK caught up. @@ -656,18 +655,71 @@ describe('ProtectSession storage', () => { expect(second?.placeholders().pid).toBe(first?.placeholders().pid); }); - it('keeps one store per origin, not one per instance', async () => { + it('shares one store between sessions of the same instance', async () => { const a = session([loader()]); a.session?.start(); serveInline(await a.injected(), { cid: a.session?.placeholders().cid }); await expect(a.session?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'ok' }); - // A second session on the same origin reads the same store rather than running its own loader. - // The token names the instance that minted it and is verified server-side, so an origin serving - // two instances costs a rejected token — never a token honoured for the wrong instance. const b = session([loader()]); expect(b.session?.hasFreshToken()).toBe(true); - expect(localStorage.getItem('__clerk_protect_st')).not.toBeNull(); + expect(localStorage.getItem(TOKEN_KEY)).not.toBeNull(); + }); + + it('does not reuse a token acquired for another instance on the same origin', async () => { + const development = session([loader()], undefined, 'example.clerk.accounts.dev'); + development.session?.start(); + serveInline(await development.injected(), { + cid: development.session?.placeholders().cid, + ready: Promise.resolve({ token: 'v1.development.mac', exp: nowSeconds() + 43_200 }), + }); + await expect(development.session?.getRequestParams()).resolves.toMatchObject({ + __clerk_protect_token: 'v1.development.mac', + }); + + const production = session([loader()]); + expect(production.session?.hasFreshToken()).toBe(false); + production.session?.start(); + serveInline(await production.injected(), { + cid: production.session?.placeholders().cid, + ready: Promise.resolve({ token: 'v1.production.mac', exp: nowSeconds() + 43_200 }), + }); + + await expect(production.session?.getRequestParams()).resolves.toMatchObject({ + __clerk_protect_token: 'v1.production.mac', + __clerk_protect_status: 'ok', + }); + expect(JSON.parse(localStorage.getItem('__clerk_protect_st:example.clerk.accounts.dev') as string)).toMatchObject({ + token: 'v1.development.mac', + }); + }); + + it('ignores a token stored under the unscoped key by an earlier version', async () => { + localStorage.setItem('__clerk_protect_st', storedEntry({ token: 'v1.legacy.mac' })); + + const { session: created, injected } = session([loader()]); + expect(created?.hasFreshToken()).toBe(false); + created?.start(); + serveInline(await injected(), { cid: created?.placeholders().cid }); + + await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_token: 'v1.payload.mac' }); + }); + + it('keeps instances apart in the in-memory fallback when storage is blocked', async () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('storage is blocked'); + }); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('storage is blocked'); + }); + + const development = session([loader()], undefined, 'example.clerk.accounts.dev'); + development.session?.start(); + serveInline(await development.injected(), { cid: development.session?.placeholders().cid }); + await expect(development.session?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'ok' }); + + expect(session([loader()]).session?.hasFreshToken()).toBe(false); + expect(session([loader()], undefined, 'example.clerk.accounts.dev').session?.hasFreshToken()).toBe(true); }); }); @@ -837,7 +889,7 @@ describe('ProtectSession default deadline', () => { const params = created?.getRequestParams(); await vi.advanceTimersByTimeAsync(7_000); - localStorage.setItem('__clerk_protect_st', storedEntry({ token: 'v1.leader.mac', rid: 'c'.repeat(26) })); + localStorage.setItem(TOKEN_KEY, storedEntry({ token: 'v1.leader.mac', rid: 'c'.repeat(26) })); await vi.advanceTimersByTimeAsync(100); await expect(params).resolves.toMatchObject({ @@ -887,7 +939,7 @@ describe('ProtectSession default deadline', () => { await vi.advanceTimersByTimeAsync(1); await expect(params).resolves.toMatchObject({ __clerk_protect_status: 'timeout' }); - localStorage.setItem('__clerk_protect_st', storedEntry({ token: 'v1.leader.mac', rid: 'c'.repeat(26) })); + localStorage.setItem(TOKEN_KEY, storedEntry({ token: 'v1.leader.mac', rid: 'c'.repeat(26) })); await vi.advanceTimersByTimeAsync(2_000); expect(vi.getTimerCount()).toBe(0); await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_token: 'v1.leader.mac' }); @@ -935,7 +987,7 @@ describe('ProtectSession default deadline', () => { }); describe('ProtectSession late token', () => { - const STORE_KEY = '__clerk_protect_st'; + const STORE_KEY = TOKEN_KEY; beforeEach(() => { vi.useFakeTimers(); @@ -1174,15 +1226,18 @@ describe('ProtectSession late token', () => { describe('ProtectSession cross-tab single flight', () => { /** - * A serialising Web Locks stand-in. jsdom has no `navigator.locks`, and the `browser-tabs-lock` - * fallback is globally mocked to always grant, so without this the double-check inside the lock - * would never be exercised. + * A Web Locks stand-in that serialises per lock name, as the real one does. jsdom has no + * `navigator.locks`, and the `browser-tabs-lock` fallback is globally mocked to always grant, so + * without this the double-check inside the lock would never be exercised. */ const installSerialisingLocks = () => { - let tail: Promise = Promise.resolve(); - const request = vi.fn((_key: string, _options: unknown, callback: () => Promise) => { - const result = tail.then(() => callback()); - tail = result.catch(() => undefined); + const tails = new Map>(); + const request = vi.fn((key: string, _options: unknown, callback: () => Promise) => { + const result = (tails.get(key) ?? Promise.resolve()).then(() => callback()); + tails.set( + key, + result.catch(() => undefined), + ); return result; }); Object.defineProperty(navigator, 'locks', { value: { request }, configurable: true }); @@ -1212,6 +1267,35 @@ describe('ProtectSession cross-tab single flight', () => { results.forEach(result => expect(result).toMatchObject({ __clerk_protect_status: 'ok' })); }); + it('acquires separately for different instances that start together on one origin', async () => { + const request = installSerialisingLocks(); + + const development = session([loader({ token_timeout_ms: 1_000 })], undefined, 'example.clerk.accounts.dev'); + const production = session([loader({ token_timeout_ms: 1_000 })]); + development.session?.start(); + production.session?.start(); + + const developmentLoader = await development.injected(); + const productionLoader = await production.injected(); + + serveInline(developmentLoader, { + cid: development.session?.placeholders().cid, + ready: Promise.resolve({ token: 'v1.development.mac', exp: nowSeconds() + 43_200 }), + }); + await expect(development.session?.getRequestParams()).resolves.toMatchObject({ + __clerk_protect_token: 'v1.development.mac', + }); + + serveInline(productionLoader, { + cid: production.session?.placeholders().cid, + ready: Promise.resolve({ token: 'v1.production.mac', exp: nowSeconds() + 43_200 }), + }); + await expect(production.session?.getRequestParams()).resolves.toMatchObject({ + __clerk_protect_token: 'v1.production.mac', + }); + expect(new Set(request.mock.calls.map(([key]) => key)).size).toBe(2); + }); + it('uses whatever a leader wrote when the lock is never granted', async () => { Object.defineProperty(navigator, 'locks', { value: { @@ -1225,7 +1309,7 @@ describe('ProtectSession cross-tab single flight', () => { created?.start(); // Written after the pre-lock read has already missed, so this can only be picked up by the // read that follows a failed lock acquisition. - localStorage.setItem('__clerk_protect_st', storedEntry({ token: 'v1.leader.mac', rid: 'c'.repeat(26) })); + localStorage.setItem(TOKEN_KEY, storedEntry({ token: 'v1.leader.mac', rid: 'c'.repeat(26) })); await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_token: 'v1.leader.mac', diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index b5ca5d2e97c..77e6bb2efa8 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -697,7 +697,7 @@ export class Clerk implements ClerkInterface { ...(telemetryEnabled && this.telemetry ? { telemetryCollector: this.telemetry } : {}), }); } - this.#protect?.load(this.environment as Environment); + this.#protect?.load(this.environment as Environment, this.frontendApi); debugLogger.info('load() complete', {}, 'clerk'); } catch (error) { this.#publicEventBus.emit(clerkEvents.Status, 'error'); diff --git a/packages/clerk-js/src/core/protect.ts b/packages/clerk-js/src/core/protect.ts index 446f0b5e7a7..2dbae44efd7 100644 --- a/packages/clerk-js/src/core/protect.ts +++ b/packages/clerk-js/src/core/protect.ts @@ -24,7 +24,7 @@ export class Protect { return this.#challengeLoadTimeoutMs; } - load(env: Environment): void { + load(env: Environment, frontendApi: string): void { const config = env?.protectConfig; if (!config?.loaders || !Array.isArray(config.loaders) || config.loaders.length === 0) { @@ -44,13 +44,13 @@ export class Protect { // The config is server-controlled and cached, so nothing it can contain may take `Clerk.load()` // down with it. try { - this.#apply(config.loaders, config.tokens_invalid_before); + this.#apply(config.loaders, frontendApi, config.tokens_invalid_before); } catch (error) { logger.warnOnce(`[protect] failed to load: ${error}`); } } - #apply(configured: ProtectLoader[], tokensInvalidBefore?: number): void { + #apply(configured: ProtectLoader[], frontendApi: string, tokensInvalidBefore?: number): void { // Rollout is decided before anything else, because the session is only meaningful for the // loaders we are actually going to apply. const loaders = configured.filter(loader => isLoader(loader) && inRollout(loader)); @@ -68,7 +68,7 @@ export class Protect { // Only an instance whose loaders reference the correlation id gets a session, so an instance // not using it is unaffected and stores nothing in the browser. const applyLoader: ApplyLoader = (loader, placeholders) => this.applyLoader(loader, placeholders); - this.#session = ProtectSession.create(loaders, applyLoader, tokensInvalidBefore); + this.#session = ProtectSession.create(loaders, applyLoader, frontendApi, tokensInvalidBefore); for (const loader of loaders) { // The session injects this one itself, under the acquisition lock, so exactly one tab per diff --git a/packages/clerk-js/src/core/protectSession.ts b/packages/clerk-js/src/core/protectSession.ts index b055b12415c..bdfb55c33e2 100644 --- a/packages/clerk-js/src/core/protectSession.ts +++ b/packages/clerk-js/src/core/protectSession.ts @@ -21,7 +21,7 @@ import { SafeLock } from './auth/safeLock'; /** Persistent, per-app-origin client id. Opaque and random — nothing about the device. */ const PID_STORAGE_KEY = '__clerk_protect_pid'; -/** The shared `{ token, exp, rid }` store every tab reads before it considers acquiring. */ +/** Prefix of the shared `{ token, exp, rid }` store every tab of an instance reads before it considers acquiring. */ const TOKEN_STORAGE_KEY = '__clerk_protect_st'; const ACQUISITION_LOCK_KEY = 'clerk.lock.protectSessionToken'; @@ -489,12 +489,12 @@ export class ProtectSession { readonly #tokenUrl?: string; readonly #timeoutMs: number; readonly #applyLoader: ApplyLoader; - /** - * One store per origin. The token is scoped to the instance that minted it and is verified - * server-side, so an origin serving two instances costs a rejected token, never a wrong grant. - */ /** Currently configured `tokens_invalid_before`; 0 when the instance has never set one. */ readonly #floor: number = 0; + /** + * One store per instance, keyed by its Frontend API, so instances sharing an origin (development + * and production, say) each acquire and send their own token. + */ readonly #tokenStorageKey: string; readonly #lock: ReturnType; @@ -512,16 +512,22 @@ export class ProtectSession { static create( loaders: ProtectLoader[], applyLoader: ApplyLoader, + frontendApi: string, tokensInvalidBefore?: number, ): ProtectSession | undefined { const templated = loaders.filter(isTemplated); if (templated.length === 0) { return undefined; } - return new ProtectSession(templated, applyLoader, tokensInvalidBefore); + return new ProtectSession(templated, applyLoader, frontendApi, tokensInvalidBefore); } - private constructor(templatedLoaders: ProtectLoader[], applyLoader: ApplyLoader, tokensInvalidBefore?: number) { + private constructor( + templatedLoaders: ProtectLoader[], + applyLoader: ApplyLoader, + frontendApi: string, + tokensInvalidBefore?: number, + ) { this.#applyLoader = applyLoader; this.#floor = normalizeFloor(tokensInvalidBefore); @@ -546,8 +552,8 @@ export class ProtectSession { this.#tokenUrl = tokenLoader ? resolveTokenUrl(tokenLoader, this.#placeholders) : undefined; this.#timeoutMs = clampTimeout(tokenLoader?.token_timeout_ms); - this.#tokenStorageKey = TOKEN_STORAGE_KEY; - this.#lock = SafeLock(ACQUISITION_LOCK_KEY); + this.#tokenStorageKey = `${TOKEN_STORAGE_KEY}:${frontendApi}`; + this.#lock = SafeLock(`${ACQUISITION_LOCK_KEY}:${frontendApi}`); } placeholders(): ProtectPlaceholders { From e5d59c5d1a15ecbde8948042df796436907381f9 Mon Sep 17 00:00:00 2001 From: Mike Wickett Date: Thu, 29 Oct 2026 19:03:37 -0400 Subject: [PATCH 2/2] test(js): restore a comment mangled by the call-site update --- packages/clerk-js/src/core/__tests__/protect.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/__tests__/protect.test.ts b/packages/clerk-js/src/core/__tests__/protect.test.ts index 88275d89e08..22ba92a6ec3 100644 --- a/packages/clerk-js/src/core/__tests__/protect.test.ts +++ b/packages/clerk-js/src/core/__tests__/protect.test.ts @@ -186,7 +186,7 @@ describe('Protect.load', () => { it('drops a malformed loader entry without failing the rest of the load', async () => { const protect = new Protect(); - // The config is server-controlled and cached; a bad entry must not take Clerk.load(, FRONTEND_API) down. + // The config is server-controlled and cached; a bad entry must not take Clerk.load() down. expect(() => protect.load(environment([null, loader({ attributes: { 'data-role': 'good' } })]), FRONTEND_API), ).not.toThrow();