From 5f859bee12f5f62e2eb435a5124e54b883053b25 Mon Sep 17 00:00:00 2001 From: brkalow Date: Tue, 24 Mar 2026 23:22:17 -0500 Subject: [PATCH 1/3] fix(clerk-js): keep dev browser token in memory to prevent stale partitioned cookie reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `partitionedCookies` is enabled via Environment, the browser treats partitioned and non-partitioned cookies with the same name as distinct. Before this fix, `getDevBrowser()` read directly from `document.cookie`, which could return a stale non-partitioned duplicate instead of the correct partitioned value. This change introduces an in-memory cache for the dev browser token so that FAPI interceptors always use the authoritative value — whether it came from the URL query param, a cookie, or a FAPI response header — regardless of cookie read ordering. Made-with: Cursor --- .../dev-browser-partitioned-cookies.test.ts | 87 +++++++ .../core/auth/__tests__/devBrowser.test.ts | 246 +++++++++++++++++- packages/clerk-js/src/core/auth/devBrowser.ts | 13 +- 3 files changed, 332 insertions(+), 14 deletions(-) create mode 100644 integration/tests/dev-browser-partitioned-cookies.test.ts diff --git a/integration/tests/dev-browser-partitioned-cookies.test.ts b/integration/tests/dev-browser-partitioned-cookies.test.ts new file mode 100644 index 00000000000..400d7fd3c77 --- /dev/null +++ b/integration/tests/dev-browser-partitioned-cookies.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from '@playwright/test'; +import { parsePublishableKey } from '@clerk/shared/keys'; + +import { appConfigs } from '../presets'; +import type { FakeUser } from '../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })( + 'dev browser partitioned cookies @generic', + ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + + test.beforeAll(async () => { + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(); + await u.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + await app.teardown(); + }); + + test('URL query param dev browser token takes precedence over existing partitioned cookie on initial load', async ({ + page, + context, + }) => { + const pk = app.env.publicVariables.get('CLERK_PUBLISHABLE_KEY'); + const { frontendApi } = parsePublishableKey(pk)!; + const fapiOrigin = `https://${frontendApi}`; + + // Obtain a valid dev browser token directly from FAPI before any page load + const devBrowserRes = await page.request.post(`${fapiOrigin}/v1/dev_browser`); + expect(devBrowserRes.ok()).toBe(true); + const { id: freshToken } = await devBrowserRes.json(); + expect(freshToken).toBeTruthy(); + + // Pre-set a stale __clerk_db_jwt cookie before the page ever loads. + // This simulates the partitioned cookie that already exists in the browser + // from a previous session. + const appUrl = new URL(app.serverUrl); + await context.addCookies([ + { + name: '__clerk_db_jwt', + value: 'stale_partitioned_value', + domain: appUrl.hostname, + path: '/', + }, + ]); + + // Collect every dev browser token attached to FAPI requests + const fapiTokens: string[] = []; + page.on('request', req => { + if (req.url().includes('__clerk_db_jwt') && req.url().includes('/v1/')) { + const url = new URL(req.url()); + const token = url.searchParams.get('__clerk_db_jwt'); + if (token) { + fapiTokens.push(token); + } + } + }); + + // Initial page load with the fresh token in the URL query param, + // simulating a redirect back from Clerk's Account Portal. + const signInUrl = new URL(app.serverUrl + '/sign-in'); + signInUrl.searchParams.set('__clerk_db_jwt', freshToken); + + await page.goto(signInUrl.toString()); + await page.waitForLoadState('networkidle'); + + // Every FAPI request during initial load must use the URL token, + // not the stale partitioned cookie. + expect(fapiTokens.length).toBeGreaterThan(0); + for (const token of fapiTokens) { + expect(token).toBe(freshToken); + expect(token).not.toBe('stale_partitioned_value'); + } + + // Verify clerk-js is functional: sign in should succeed + const u = createTestUtils({ app, page, context }); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + }); + }, +); diff --git a/packages/clerk-js/src/core/auth/__tests__/devBrowser.test.ts b/packages/clerk-js/src/core/auth/__tests__/devBrowser.test.ts index 9da60d8ba08..dd2f5e2afe0 100644 --- a/packages/clerk-js/src/core/auth/__tests__/devBrowser.test.ts +++ b/packages/clerk-js/src/core/auth/__tests__/devBrowser.test.ts @@ -7,6 +7,22 @@ type RecursivePartial = { [P in keyof T]?: RecursivePartial; }; +function mockFapiClient() { + return { + buildUrl: vi.fn(() => 'https://white-koala-42.clerk.accounts.dev/dev_browser'), + onAfterResponse: vi.fn(), + onBeforeRequest: vi.fn(), + } as unknown as FapiClient; +} + +const DEV_FRONTEND_API = 'white-koala-42.clerk.accounts.dev'; +const COOKIE_SUFFIX = 'test-suffix'; + +function clearDevBrowserCookies() { + document.cookie = `__clerk_db_jwt=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; + document.cookie = `__clerk_db_jwt_${COOKIE_SUFFIX}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; +} + describe('Thrown errors', () => { beforeEach(() => { // @ts-ignore @@ -37,25 +53,231 @@ describe('Thrown errors', () => { // Note: The test runs without any initial or mocked values on __clerk_db_jwt cookies. // It is expected to modify the test accordingly if cookies are mocked for future extra testing. it('throws any FAPI errors during dev browser creation', async () => { - const mockCreateFapiClient = vi.fn().mockImplementation(() => { - return { - buildUrl: vi.fn(() => 'https://white-koala-42.clerk.accounts.dev/dev_browser'), - onAfterResponse: vi.fn(), - onBeforeRequest: vi.fn(), - }; + const devBrowserHandler = createDevBrowser({ + frontendApi: DEV_FRONTEND_API, + fapiClient: mockFapiClient(), + cookieSuffix: COOKIE_SUFFIX, + cookieOptions: { usePartitionedCookies: () => false }, }); - const mockFapiClient = mockCreateFapiClient() as FapiClient; + await expect(devBrowserHandler.setup()).rejects.toThrow( + 'ClerkJS: Something went wrong initializing Clerk in development mode. This is a development instance operating with legacy, third-party cookies. To enable URL-based session syncing refer to https://clerk.com/docs/upgrade-guides/url-based-session-syncing.', + ); + }); +}); + +describe('In-memory dev browser token', () => { + afterEach(() => { + // @ts-ignore + vi.mocked(global.fetch)?.mockClear(); + clearDevBrowserCookies(); + }); + + it('getDevBrowser returns in-memory value even when cookie is cleared', async () => { + const devBrowserId = 'dev_browser_abc123'; + + // @ts-ignore + global.fetch = vi.fn(() => + Promise.resolve>({ + ok: true, + json: () => Promise.resolve({ id: devBrowserId }), + }), + ); const devBrowserHandler = createDevBrowser({ - frontendApi: 'white-koala-42.clerk.accounts.dev', - fapiClient: mockFapiClient, - cookieSuffix: 'test-suffix', + frontendApi: DEV_FRONTEND_API, + fapiClient: mockFapiClient(), + cookieSuffix: COOKIE_SUFFIX, cookieOptions: { usePartitionedCookies: () => false }, }); - await expect(devBrowserHandler.setup()).rejects.toThrow( - 'ClerkJS: Something went wrong initializing Clerk in development mode. This is a development instance operating with legacy, third-party cookies. To enable URL-based session syncing refer to https://clerk.com/docs/upgrade-guides/url-based-session-syncing.', + await devBrowserHandler.setup(); + expect(devBrowserHandler.getDevBrowser()).toBe(devBrowserId); + + // Simulate cookie being unreadable (e.g. blocked in third-party context) + clearDevBrowserCookies(); + + // In-memory value survives cookie loss + expect(devBrowserHandler.getDevBrowser()).toBe(devBrowserId); + }); + + it('refreshCookies uses in-memory value when cookie is gone', async () => { + const devBrowserId = 'dev_browser_xyz789'; + + // @ts-ignore + global.fetch = vi.fn(() => + Promise.resolve>({ + ok: true, + json: () => Promise.resolve({ id: devBrowserId }), + }), + ); + + const devBrowserHandler = createDevBrowser({ + frontendApi: DEV_FRONTEND_API, + fapiClient: mockFapiClient(), + cookieSuffix: COOKIE_SUFFIX, + cookieOptions: { usePartitionedCookies: () => false }, + }); + + await devBrowserHandler.setup(); + + // Wipe cookies to simulate failed initial write in third-party context + clearDevBrowserCookies(); + + // refreshCookies should still recover from the in-memory value + devBrowserHandler.refreshCookies(); + expect(devBrowserHandler.getDevBrowser()).toBe(devBrowserId); + }); + + it('clear removes both in-memory value and cookie', () => { + const devBrowserHandler = createDevBrowser({ + frontendApi: DEV_FRONTEND_API, + fapiClient: mockFapiClient(), + cookieSuffix: COOKIE_SUFFIX, + cookieOptions: { usePartitionedCookies: () => false }, + }); + + devBrowserHandler.setDevBrowser('dev_browser_token'); + expect(devBrowserHandler.getDevBrowser()).toBe('dev_browser_token'); + + devBrowserHandler.clear(); + expect(devBrowserHandler.getDevBrowser()).toBeUndefined(); + }); + + it('setDevBrowser updates in-memory value', () => { + const devBrowserHandler = createDevBrowser({ + frontendApi: DEV_FRONTEND_API, + fapiClient: mockFapiClient(), + cookieSuffix: COOKIE_SUFFIX, + cookieOptions: { usePartitionedCookies: () => false }, + }); + + devBrowserHandler.setDevBrowser('token_1'); + expect(devBrowserHandler.getDevBrowser()).toBe('token_1'); + + devBrowserHandler.setDevBrowser('token_2'); + expect(devBrowserHandler.getDevBrowser()).toBe('token_2'); + }); +}); + +describe('Duplicate cookie from partitionedCookies transition', () => { + afterEach(() => { + // @ts-ignore + vi.mocked(global.fetch)?.mockClear(); + clearDevBrowserCookies(); + }); + + it('in-memory value takes precedence when cookie read returns stale non-partitioned duplicate', async () => { + const initialToken = 'dev_browser_initial'; + const rotatedToken = 'dev_browser_rotated'; + + // @ts-ignore + global.fetch = vi.fn(() => + Promise.resolve>({ + ok: true, + json: () => Promise.resolve({ id: initialToken }), + }), + ); + + let afterResponseCb: (_: unknown, res: RecursivePartial) => void = () => {}; + const fapiClient = { + ...mockFapiClient(), + onAfterResponse: vi.fn((cb: typeof afterResponseCb) => { + afterResponseCb = cb; + }), + } as unknown as FapiClient; + + let partitioned = false; + const devBrowserHandler = createDevBrowser({ + frontendApi: DEV_FRONTEND_API, + fapiClient, + cookieSuffix: COOKIE_SUFFIX, + cookieOptions: { usePartitionedCookies: () => partitioned }, + }); + + // 1. Setup: POST /dev_browser returns initialToken, written as non-partitioned cookie + await devBrowserHandler.setup(); + expect(devBrowserHandler.getDevBrowser()).toBe(initialToken); + + // 2. FAPI response rotates the token — both memory and cookie are updated + afterResponseCb(null, { + headers: new Headers({ 'Clerk-Db-Jwt': rotatedToken }), + }); + expect(devBrowserHandler.getDevBrowser()).toBe(rotatedToken); + + // 3. Simulate the duplicate cookie problem: the browser has both a + // non-partitioned cookie (stale initialToken) and a partitioned cookie + // (rotatedToken). document.cookie returns the stale one first. + // We simulate this by writing the stale value back to the cookie. + document.cookie = `__clerk_db_jwt=${initialToken}; path=/`; + document.cookie = `__clerk_db_jwt_${COOKIE_SUFFIX}=${initialToken}; path=/`; + + // 4. Without in-memory, getDevBrowser() would read the stale cookie value. + // With in-memory, it returns the correct rotated token. + expect(devBrowserHandler.getDevBrowser()).toBe(rotatedToken); + + // 5. Environment resolves with partitionedCookies: true, refreshCookies fires. + // It must use the in-memory rotatedToken, not the stale cookie value. + partitioned = true; + devBrowserHandler.refreshCookies(); + expect(devBrowserHandler.getDevBrowser()).toBe(rotatedToken); + }); + + it('FAPI interceptor attaches correct token despite stale cookie from duplicate', async () => { + const initialToken = 'dev_browser_stale'; + const rotatedToken = 'dev_browser_fresh'; + + // @ts-ignore + global.fetch = vi.fn(() => + Promise.resolve>({ + ok: true, + json: () => Promise.resolve({ id: initialToken }), + }), ); + + let beforeRequestCb: (req: { url?: URL }) => void = () => {}; + let afterResponseCb: (_: unknown, res: RecursivePartial) => void = () => {}; + const fapiClient = { + ...mockFapiClient(), + onBeforeRequest: vi.fn((cb: typeof beforeRequestCb) => { + beforeRequestCb = cb; + }), + onAfterResponse: vi.fn((cb: typeof afterResponseCb) => { + afterResponseCb = cb; + }), + } as unknown as FapiClient; + + let partitioned = false; + const devBrowserHandler = createDevBrowser({ + frontendApi: DEV_FRONTEND_API, + fapiClient, + cookieSuffix: COOKIE_SUFFIX, + cookieOptions: { usePartitionedCookies: () => partitioned }, + }); + + // Setup writes initialToken as non-partitioned + await devBrowserHandler.setup(); + + // Token rotates via FAPI response + afterResponseCb(null, { + headers: new Headers({ 'Clerk-Db-Jwt': rotatedToken }), + }); + + // Simulate duplicate: stale non-partitioned cookie shadows the partitioned one + document.cookie = `__clerk_db_jwt=${initialToken}; path=/`; + document.cookie = `__clerk_db_jwt_${COOKIE_SUFFIX}=${initialToken}; path=/`; + + // FAPI interceptor must attach the rotated token, not the stale cookie + const request = { url: new URL('https://white-koala-42.clerk.accounts.dev/v1/client') }; + beforeRequestCb(request); + expect(request.url.searchParams.get('__clerk_db_jwt')).toBe(rotatedToken); + + // After Environment resolves and refreshCookies runs, still correct + partitioned = true; + devBrowserHandler.refreshCookies(); + + const request2 = { url: new URL('https://white-koala-42.clerk.accounts.dev/v1/environment') }; + beforeRequestCb(request2); + expect(request2.url.searchParams.get('__clerk_db_jwt')).toBe(rotatedToken); }); }); diff --git a/packages/clerk-js/src/core/auth/devBrowser.ts b/packages/clerk-js/src/core/auth/devBrowser.ts index 2bea7c52049..4572221f21d 100644 --- a/packages/clerk-js/src/core/auth/devBrowser.ts +++ b/packages/clerk-js/src/core/auth/devBrowser.ts @@ -37,15 +37,22 @@ export function createDevBrowser({ }: CreateDevBrowserOptions): DevBrowser { const devBrowserCookie = createDevBrowserCookie(cookieSuffix, cookieOptions); + // Hold the dev browser token in memory so it's always available to FAPI + // interceptors, even before Environment resolves and cookies can be written + // with the correct Partitioned attribute. + let devBrowserInMemory: string | undefined; + function getDevBrowser() { - return devBrowserCookie.get(); + return devBrowserInMemory || devBrowserCookie.get(); } function setDevBrowser(devBrowser: string) { + devBrowserInMemory = devBrowser; devBrowserCookie.set(devBrowser); } function removeDevBrowser() { + devBrowserInMemory = undefined; devBrowserCookie.remove(); } @@ -81,7 +88,9 @@ export function createDevBrowser({ } // 2. If no dev browser is found in the first step, check if one is already available in the __clerk_db_jwt JS cookie - if (devBrowserCookie.get()) { + const existingDevBrowser = devBrowserCookie.get(); + if (existingDevBrowser) { + devBrowserInMemory = existingDevBrowser; return; } From 8bb6bbb02ec2079c4b23ebb17bb0f872143e4960 Mon Sep 17 00:00:00 2001 From: brkalow Date: Tue, 24 Mar 2026 23:25:08 -0500 Subject: [PATCH 2/3] revert(clerk-js): undo unit test changes for devBrowser Made-with: Cursor --- .../core/auth/__tests__/devBrowser.test.ts | 246 +----------------- 1 file changed, 12 insertions(+), 234 deletions(-) diff --git a/packages/clerk-js/src/core/auth/__tests__/devBrowser.test.ts b/packages/clerk-js/src/core/auth/__tests__/devBrowser.test.ts index dd2f5e2afe0..9da60d8ba08 100644 --- a/packages/clerk-js/src/core/auth/__tests__/devBrowser.test.ts +++ b/packages/clerk-js/src/core/auth/__tests__/devBrowser.test.ts @@ -7,22 +7,6 @@ type RecursivePartial = { [P in keyof T]?: RecursivePartial; }; -function mockFapiClient() { - return { - buildUrl: vi.fn(() => 'https://white-koala-42.clerk.accounts.dev/dev_browser'), - onAfterResponse: vi.fn(), - onBeforeRequest: vi.fn(), - } as unknown as FapiClient; -} - -const DEV_FRONTEND_API = 'white-koala-42.clerk.accounts.dev'; -const COOKIE_SUFFIX = 'test-suffix'; - -function clearDevBrowserCookies() { - document.cookie = `__clerk_db_jwt=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; - document.cookie = `__clerk_db_jwt_${COOKIE_SUFFIX}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; -} - describe('Thrown errors', () => { beforeEach(() => { // @ts-ignore @@ -53,231 +37,25 @@ describe('Thrown errors', () => { // Note: The test runs without any initial or mocked values on __clerk_db_jwt cookies. // It is expected to modify the test accordingly if cookies are mocked for future extra testing. it('throws any FAPI errors during dev browser creation', async () => { - const devBrowserHandler = createDevBrowser({ - frontendApi: DEV_FRONTEND_API, - fapiClient: mockFapiClient(), - cookieSuffix: COOKIE_SUFFIX, - cookieOptions: { usePartitionedCookies: () => false }, - }); - - await expect(devBrowserHandler.setup()).rejects.toThrow( - 'ClerkJS: Something went wrong initializing Clerk in development mode. This is a development instance operating with legacy, third-party cookies. To enable URL-based session syncing refer to https://clerk.com/docs/upgrade-guides/url-based-session-syncing.', - ); - }); -}); - -describe('In-memory dev browser token', () => { - afterEach(() => { - // @ts-ignore - vi.mocked(global.fetch)?.mockClear(); - clearDevBrowserCookies(); - }); - - it('getDevBrowser returns in-memory value even when cookie is cleared', async () => { - const devBrowserId = 'dev_browser_abc123'; - - // @ts-ignore - global.fetch = vi.fn(() => - Promise.resolve>({ - ok: true, - json: () => Promise.resolve({ id: devBrowserId }), - }), - ); - - const devBrowserHandler = createDevBrowser({ - frontendApi: DEV_FRONTEND_API, - fapiClient: mockFapiClient(), - cookieSuffix: COOKIE_SUFFIX, - cookieOptions: { usePartitionedCookies: () => false }, - }); - - await devBrowserHandler.setup(); - expect(devBrowserHandler.getDevBrowser()).toBe(devBrowserId); - - // Simulate cookie being unreadable (e.g. blocked in third-party context) - clearDevBrowserCookies(); - - // In-memory value survives cookie loss - expect(devBrowserHandler.getDevBrowser()).toBe(devBrowserId); - }); - - it('refreshCookies uses in-memory value when cookie is gone', async () => { - const devBrowserId = 'dev_browser_xyz789'; - - // @ts-ignore - global.fetch = vi.fn(() => - Promise.resolve>({ - ok: true, - json: () => Promise.resolve({ id: devBrowserId }), - }), - ); - - const devBrowserHandler = createDevBrowser({ - frontendApi: DEV_FRONTEND_API, - fapiClient: mockFapiClient(), - cookieSuffix: COOKIE_SUFFIX, - cookieOptions: { usePartitionedCookies: () => false }, - }); - - await devBrowserHandler.setup(); - - // Wipe cookies to simulate failed initial write in third-party context - clearDevBrowserCookies(); - - // refreshCookies should still recover from the in-memory value - devBrowserHandler.refreshCookies(); - expect(devBrowserHandler.getDevBrowser()).toBe(devBrowserId); - }); - - it('clear removes both in-memory value and cookie', () => { - const devBrowserHandler = createDevBrowser({ - frontendApi: DEV_FRONTEND_API, - fapiClient: mockFapiClient(), - cookieSuffix: COOKIE_SUFFIX, - cookieOptions: { usePartitionedCookies: () => false }, + const mockCreateFapiClient = vi.fn().mockImplementation(() => { + return { + buildUrl: vi.fn(() => 'https://white-koala-42.clerk.accounts.dev/dev_browser'), + onAfterResponse: vi.fn(), + onBeforeRequest: vi.fn(), + }; }); - devBrowserHandler.setDevBrowser('dev_browser_token'); - expect(devBrowserHandler.getDevBrowser()).toBe('dev_browser_token'); + const mockFapiClient = mockCreateFapiClient() as FapiClient; - devBrowserHandler.clear(); - expect(devBrowserHandler.getDevBrowser()).toBeUndefined(); - }); - - it('setDevBrowser updates in-memory value', () => { const devBrowserHandler = createDevBrowser({ - frontendApi: DEV_FRONTEND_API, - fapiClient: mockFapiClient(), - cookieSuffix: COOKIE_SUFFIX, + frontendApi: 'white-koala-42.clerk.accounts.dev', + fapiClient: mockFapiClient, + cookieSuffix: 'test-suffix', cookieOptions: { usePartitionedCookies: () => false }, }); - devBrowserHandler.setDevBrowser('token_1'); - expect(devBrowserHandler.getDevBrowser()).toBe('token_1'); - - devBrowserHandler.setDevBrowser('token_2'); - expect(devBrowserHandler.getDevBrowser()).toBe('token_2'); - }); -}); - -describe('Duplicate cookie from partitionedCookies transition', () => { - afterEach(() => { - // @ts-ignore - vi.mocked(global.fetch)?.mockClear(); - clearDevBrowserCookies(); - }); - - it('in-memory value takes precedence when cookie read returns stale non-partitioned duplicate', async () => { - const initialToken = 'dev_browser_initial'; - const rotatedToken = 'dev_browser_rotated'; - - // @ts-ignore - global.fetch = vi.fn(() => - Promise.resolve>({ - ok: true, - json: () => Promise.resolve({ id: initialToken }), - }), - ); - - let afterResponseCb: (_: unknown, res: RecursivePartial) => void = () => {}; - const fapiClient = { - ...mockFapiClient(), - onAfterResponse: vi.fn((cb: typeof afterResponseCb) => { - afterResponseCb = cb; - }), - } as unknown as FapiClient; - - let partitioned = false; - const devBrowserHandler = createDevBrowser({ - frontendApi: DEV_FRONTEND_API, - fapiClient, - cookieSuffix: COOKIE_SUFFIX, - cookieOptions: { usePartitionedCookies: () => partitioned }, - }); - - // 1. Setup: POST /dev_browser returns initialToken, written as non-partitioned cookie - await devBrowserHandler.setup(); - expect(devBrowserHandler.getDevBrowser()).toBe(initialToken); - - // 2. FAPI response rotates the token — both memory and cookie are updated - afterResponseCb(null, { - headers: new Headers({ 'Clerk-Db-Jwt': rotatedToken }), - }); - expect(devBrowserHandler.getDevBrowser()).toBe(rotatedToken); - - // 3. Simulate the duplicate cookie problem: the browser has both a - // non-partitioned cookie (stale initialToken) and a partitioned cookie - // (rotatedToken). document.cookie returns the stale one first. - // We simulate this by writing the stale value back to the cookie. - document.cookie = `__clerk_db_jwt=${initialToken}; path=/`; - document.cookie = `__clerk_db_jwt_${COOKIE_SUFFIX}=${initialToken}; path=/`; - - // 4. Without in-memory, getDevBrowser() would read the stale cookie value. - // With in-memory, it returns the correct rotated token. - expect(devBrowserHandler.getDevBrowser()).toBe(rotatedToken); - - // 5. Environment resolves with partitionedCookies: true, refreshCookies fires. - // It must use the in-memory rotatedToken, not the stale cookie value. - partitioned = true; - devBrowserHandler.refreshCookies(); - expect(devBrowserHandler.getDevBrowser()).toBe(rotatedToken); - }); - - it('FAPI interceptor attaches correct token despite stale cookie from duplicate', async () => { - const initialToken = 'dev_browser_stale'; - const rotatedToken = 'dev_browser_fresh'; - - // @ts-ignore - global.fetch = vi.fn(() => - Promise.resolve>({ - ok: true, - json: () => Promise.resolve({ id: initialToken }), - }), + await expect(devBrowserHandler.setup()).rejects.toThrow( + 'ClerkJS: Something went wrong initializing Clerk in development mode. This is a development instance operating with legacy, third-party cookies. To enable URL-based session syncing refer to https://clerk.com/docs/upgrade-guides/url-based-session-syncing.', ); - - let beforeRequestCb: (req: { url?: URL }) => void = () => {}; - let afterResponseCb: (_: unknown, res: RecursivePartial) => void = () => {}; - const fapiClient = { - ...mockFapiClient(), - onBeforeRequest: vi.fn((cb: typeof beforeRequestCb) => { - beforeRequestCb = cb; - }), - onAfterResponse: vi.fn((cb: typeof afterResponseCb) => { - afterResponseCb = cb; - }), - } as unknown as FapiClient; - - let partitioned = false; - const devBrowserHandler = createDevBrowser({ - frontendApi: DEV_FRONTEND_API, - fapiClient, - cookieSuffix: COOKIE_SUFFIX, - cookieOptions: { usePartitionedCookies: () => partitioned }, - }); - - // Setup writes initialToken as non-partitioned - await devBrowserHandler.setup(); - - // Token rotates via FAPI response - afterResponseCb(null, { - headers: new Headers({ 'Clerk-Db-Jwt': rotatedToken }), - }); - - // Simulate duplicate: stale non-partitioned cookie shadows the partitioned one - document.cookie = `__clerk_db_jwt=${initialToken}; path=/`; - document.cookie = `__clerk_db_jwt_${COOKIE_SUFFIX}=${initialToken}; path=/`; - - // FAPI interceptor must attach the rotated token, not the stale cookie - const request = { url: new URL('https://white-koala-42.clerk.accounts.dev/v1/client') }; - beforeRequestCb(request); - expect(request.url.searchParams.get('__clerk_db_jwt')).toBe(rotatedToken); - - // After Environment resolves and refreshCookies runs, still correct - partitioned = true; - devBrowserHandler.refreshCookies(); - - const request2 = { url: new URL('https://white-koala-42.clerk.accounts.dev/v1/environment') }; - beforeRequestCb(request2); - expect(request2.url.searchParams.get('__clerk_db_jwt')).toBe(rotatedToken); }); }); From 88409f4a188a5a5d8e90f9db06adc48add003d96 Mon Sep 17 00:00:00 2001 From: brkalow Date: Tue, 24 Mar 2026 23:25:30 -0500 Subject: [PATCH 3/3] fix(clerk-js): add changeset for dev browser partitioned cookie fix Made-with: Cursor --- .changeset/fix-dev-browser-partitioned-cookie.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-dev-browser-partitioned-cookie.md diff --git a/.changeset/fix-dev-browser-partitioned-cookie.md b/.changeset/fix-dev-browser-partitioned-cookie.md new file mode 100644 index 00000000000..e4d561b52c6 --- /dev/null +++ b/.changeset/fix-dev-browser-partitioned-cookie.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Fix dev browser token being read from a stale non-partitioned cookie when `partitionedCookies` is enabled. The token is now kept in memory so FAPI requests always use the authoritative value.