From 9cbed1e106c291862d935a49860d1470867dd335 Mon Sep 17 00:00:00 2001 From: Muhammad Umer Aamir <93091750+umeraamir69@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:32:21 -0400 Subject: [PATCH 1/2] Rewrite the dev browser cookie without leaving a missing-cookie window. clerk-js was deleting both __clerk_db_jwt cookies before writing them again, so concurrent requests on development instances hit clerkMiddleware as signed-out. Write the new cookies first, then drop only stale attribute variants, and fall back to the unsuffixed cookie on the backend when the suffixed one is gone. --- .changeset/dev-browser-cookie-atomic.md | 6 ++++ .../__tests__/authenticateContext.test.ts | 23 ++++++++++++ .../backend/src/tokens/authenticateContext.ts | 5 ++- .../auth/cookies/__tests__/devBrowser.test.ts | 36 ++++++++++--------- .../src/core/auth/cookies/devBrowser.ts | 23 +++++++++--- 5 files changed, 70 insertions(+), 23 deletions(-) create mode 100644 .changeset/dev-browser-cookie-atomic.md diff --git a/.changeset/dev-browser-cookie-atomic.md b/.changeset/dev-browser-cookie-atomic.md new file mode 100644 index 00000000000..10a914a77a2 --- /dev/null +++ b/.changeset/dev-browser-cookie-atomic.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/backend': patch +--- + +Development instances no longer drop the suffixed `__clerk_db_jwt` cookie while rewriting it, which was causing intermittent `dev-browser-missing` 401s on concurrent requests. diff --git a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts index 0ae390a1f79..fd299e1dd60 100644 --- a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts +++ b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts @@ -172,6 +172,29 @@ describe('AuthenticateContext', () => { expect(context.devBrowserToken).toBe('__clerk_db_jwt-suffixed'); }); + /** + * @see https://github.com/clerk/javascript/issues/10411 + */ + it('dev: falls back to unsuffixed __clerk_db_jwt when the suffixed cookie is missing', async () => { + const headers = new Headers({ + cookie: createCookieHeader({ + __client_uat: clientUat, + __client_uat_vWCgMp3A: suffixedClientUat, + __session: session, + __session_vWCgMp3A: suffixedSession, + __clerk_db_jwt: '__clerk_db_jwt', + }), + }); + const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); + const context = await createAuthenticateContext(clerkRequest, { + publishableKey: pkTest, + }); + + expect(context.usesSuffixedCookies()).toBe(true); + expect(context.sessionTokenInCookie).toBe(suffixedSession); + expect(context.devBrowserToken).toBe('__clerk_db_jwt'); + }); + it('dev: request with suffixed session and signed-out suffixed client_uat', async () => { const headers = new Headers({ cookie: createCookieHeader({ diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index 427b49ae7b3..8d427b1103b 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -317,7 +317,10 @@ class AuthenticateContext implements AuthenticateContext { private initHandshakeValues() { this.devBrowserToken = this.getQueryParam(constants.QueryParameters.DevBrowser) || - this.getSuffixedOrUnSuffixedCookie(constants.Cookies.DevBrowser); + (this.usesSuffixedCookies() + ? this.getSuffixedCookie(constants.Cookies.DevBrowser) || + this.getCookie(constants.Cookies.DevBrowser) + : this.getCookie(constants.Cookies.DevBrowser)); // Using getCookie since we don't suffix the handshake token cookie this.handshakeToken = this.getQueryParam(constants.QueryParameters.Handshake) || this.getCookie(constants.Cookies.Handshake); diff --git a/packages/clerk-js/src/core/auth/cookies/__tests__/devBrowser.test.ts b/packages/clerk-js/src/core/auth/cookies/__tests__/devBrowser.test.ts index 49176d9966b..59a97be5ed8 100644 --- a/packages/clerk-js/src/core/auth/cookies/__tests__/devBrowser.test.ts +++ b/packages/clerk-js/src/core/auth/cookies/__tests__/devBrowser.test.ts @@ -87,27 +87,11 @@ describe('createDevBrowserCookie', () => { ]); }); - it('clears stale partitioned cookie variants before writing a new dev browser', () => { + it('writes both cookies before stripping stale partitioned variants', () => { const cookieHandler = createDevBrowserCookie(cookieSuffix, defaultOptions); cookieHandler.set(devBrowser); - expect(removeCalls).toContainEqual({ - name: suffixedCookieName, - attributes: { - sameSite: 'None', - secure: true, - partitioned: true, - }, - }); - expect(removeCalls).toContainEqual({ - name: unsuffixedCookieName, - attributes: { - sameSite: 'None', - secure: true, - partitioned: true, - }, - }); expect(setCalls).toEqual([ { name: suffixedCookieName, @@ -130,6 +114,24 @@ describe('createDevBrowserCookie', () => { }, }, ]); + expect(removeCalls).toEqual([ + { + name: suffixedCookieName, + attributes: { + sameSite: 'None', + secure: true, + partitioned: true, + }, + }, + { + name: unsuffixedCookieName, + attributes: { + sameSite: 'None', + secure: true, + partitioned: true, + }, + }, + ]); }); it('reads the suffixed cookie before falling back to the unsuffixed cookie', () => { diff --git a/packages/clerk-js/src/core/auth/cookies/devBrowser.ts b/packages/clerk-js/src/core/auth/cookies/devBrowser.ts index d4244e6de41..7cb670c6937 100644 --- a/packages/clerk-js/src/core/auth/cookies/devBrowser.ts +++ b/packages/clerk-js/src/core/auth/cookies/devBrowser.ts @@ -46,26 +46,39 @@ export const createDevBrowserCookie = ( const get = () => suffixedDevBrowserCookie.get() || devBrowserCookie.get(); + const cookies = [suffixedDevBrowserCookie, devBrowserCookie]; + const removeAll = () => { const attributes = getCookieAttributes(options); - for (const cookie of [suffixedDevBrowserCookie, devBrowserCookie]) { + for (const cookie of cookies) { cookie.remove(attributes); cookie.remove(); cookie.remove(partitionedCookieAttributes); } }; + const removeStaleAttributeVariants = (wrotePartitioned: boolean) => { + for (const cookie of cookies) { + if (wrotePartitioned) { + cookie.remove({ sameSite: 'Lax', secure: getSecureAttribute('Lax'), partitioned: false }); + cookie.remove({ sameSite: 'None', secure: getSecureAttribute('None'), partitioned: false }); + } else { + cookie.remove(partitionedCookieAttributes); + } + } + }; + const set = (devBrowser: string) => { const expires = addYears(Date.now(), 1); const { sameSite, secure, partitioned } = getCookieAttributes(options); - // Remove stale variants before writing. The environment may not be loaded - // yet, so the current partitioned-cookies setting cannot be trusted. - removeAll(); - + // Write both cookies first so a concurrent request never sees a hole + // between remove() and set(). Then drop only the attribute variants + // that are not the ones we just wrote. suffixedDevBrowserCookie.set(devBrowser, { expires, sameSite, secure, partitioned }); devBrowserCookie.set(devBrowser, { expires, sameSite, secure, partitioned }); + removeStaleAttributeVariants(partitioned); }; const remove = () => removeAll(); From 81dfd9e2d16ec2467404fb4d9b43dee2858a54b5 Mon Sep 17 00:00:00 2001 From: Muhammad Umer Aamir <93091750+umeraamir69@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:44:01 -0400 Subject: [PATCH 2/2] Skip partitioned cookie cleanup after an unpartitioned dev-browser write. Safari can treat a Partitioned expiry as applying to the cookie we just wrote. Also keep a suffixed __clerk_db_jwt fallback when session cookies are unsuffixed. --- .../__tests__/authenticateContext.test.ts | 17 +++++++ .../backend/src/tokens/authenticateContext.ts | 3 +- .../auth/cookies/__tests__/devBrowser.test.ts | 44 ++++++++++--------- .../src/core/auth/cookies/devBrowser.ts | 15 ++++--- 4 files changed, 52 insertions(+), 27 deletions(-) diff --git a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts index fd299e1dd60..54b5a7d72dc 100644 --- a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts +++ b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts @@ -27,6 +27,23 @@ describe('AuthenticateContext', () => { describe('suffixedCookies', () => { describe('use un-suffixed cookies', () => { + it('still reads a suffixed __clerk_db_jwt when session cookies are un-suffixed', async () => { + const headers = new Headers({ + cookie: createCookieHeader({ + __client_uat: clientUat, + __session: session, + __clerk_db_jwt_MqCvchyS: '__clerk_db_jwt-suffixed', + }), + }); + const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); + const context = await createAuthenticateContext(clerkRequest, { + publishableKey: pkLive, + }); + + expect(context.usesSuffixedCookies()).toBe(false); + expect(context.devBrowserToken).toBe('__clerk_db_jwt-suffixed'); + }); + it('request with un-suffixed cookies', async () => { const headers = new Headers({ cookie: createCookieHeader({ diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index 8d427b1103b..af2f8843df9 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -320,7 +320,8 @@ class AuthenticateContext implements AuthenticateContext { (this.usesSuffixedCookies() ? this.getSuffixedCookie(constants.Cookies.DevBrowser) || this.getCookie(constants.Cookies.DevBrowser) - : this.getCookie(constants.Cookies.DevBrowser)); + : this.getCookie(constants.Cookies.DevBrowser) || + this.getSuffixedCookie(constants.Cookies.DevBrowser)); // Using getCookie since we don't suffix the handshake token cookie this.handshakeToken = this.getQueryParam(constants.QueryParameters.Handshake) || this.getCookie(constants.Cookies.Handshake); diff --git a/packages/clerk-js/src/core/auth/cookies/__tests__/devBrowser.test.ts b/packages/clerk-js/src/core/auth/cookies/__tests__/devBrowser.test.ts index 59a97be5ed8..d934776abf3 100644 --- a/packages/clerk-js/src/core/auth/cookies/__tests__/devBrowser.test.ts +++ b/packages/clerk-js/src/core/auth/cookies/__tests__/devBrowser.test.ts @@ -2,10 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createDevBrowserCookie } from '../devBrowser'; -const { cookieStore, removeCalls, setCalls } = vi.hoisted(() => ({ +const { cookieStore, removeCalls, setCalls, ops } = vi.hoisted(() => ({ cookieStore: new Map(), removeCalls: [] as Array<{ name: string; attributes?: object }>, setCalls: [] as Array<{ name: string; value: string; attributes?: object }>, + ops: [] as Array<{ op: 'set' | 'remove'; name: string }>, })); vi.mock('@clerk/shared/cookie', () => ({ @@ -13,10 +14,12 @@ vi.mock('@clerk/shared/cookie', () => ({ get: () => cookieStore.get(name), remove: (attributes?: object) => { removeCalls.push({ name, attributes }); + ops.push({ op: 'remove', name }); cookieStore.delete(name); }, set: (value: string, attributes?: object) => { setCalls.push({ name, value, attributes }); + ops.push({ op: 'set', name }); cookieStore.set(name, value); }, }), @@ -37,6 +40,7 @@ describe('createDevBrowserCookie', () => { cookieStore.clear(); removeCalls.length = 0; setCalls.length = 0; + ops.length = 0; }); afterEach(() => { @@ -87,7 +91,7 @@ describe('createDevBrowserCookie', () => { ]); }); - it('writes both cookies before stripping stale partitioned variants', () => { + it('writes both cookies and does not remove partitioned variants after an unpartitioned write', () => { const cookieHandler = createDevBrowserCookie(cookieSuffix, defaultOptions); cookieHandler.set(devBrowser); @@ -114,24 +118,24 @@ describe('createDevBrowserCookie', () => { }, }, ]); - expect(removeCalls).toEqual([ - { - name: suffixedCookieName, - attributes: { - sameSite: 'None', - secure: true, - partitioned: true, - }, - }, - { - name: unsuffixedCookieName, - attributes: { - sameSite: 'None', - secure: true, - partitioned: true, - }, - }, - ]); + expect(removeCalls).toEqual([]); + expect(ops.filter(op => op.op === 'remove')).toEqual([]); + expect(cookieStore.get(suffixedCookieName)).toBe(devBrowser); + expect(cookieStore.get(unsuffixedCookieName)).toBe(devBrowser); + }); + + it('writes both partitioned cookies before stripping unpartitioned variants', () => { + vi.stubGlobal('location', { protocol: 'https:', hostname: 'example.com' }); + const cookieHandler = createDevBrowserCookie(cookieSuffix, { + usePartitionedCookies: () => true, + }); + + cookieHandler.set(devBrowser); + + expect(ops.map(op => op.op)).toEqual(['set', 'set', 'remove', 'remove', 'remove', 'remove']); + expect(ops[0]).toEqual({ op: 'set', name: suffixedCookieName }); + expect(ops[1]).toEqual({ op: 'set', name: unsuffixedCookieName }); + expect(ops.slice(2).every(op => op.op === 'remove')).toBe(true); }); it('reads the suffixed cookie before falling back to the unsuffixed cookie', () => { diff --git a/packages/clerk-js/src/core/auth/cookies/devBrowser.ts b/packages/clerk-js/src/core/auth/cookies/devBrowser.ts index 7cb670c6937..1829108d098 100644 --- a/packages/clerk-js/src/core/auth/cookies/devBrowser.ts +++ b/packages/clerk-js/src/core/auth/cookies/devBrowser.ts @@ -59,13 +59,16 @@ export const createDevBrowserCookie = ( }; const removeStaleAttributeVariants = (wrotePartitioned: boolean) => { + // Only strip the variants we did not just write. Removing Partitioned + // after an unpartitioned write can expire the live cookie on Safari + // builds that ignore the Partitioned attribute. + if (!wrotePartitioned) { + return; + } + for (const cookie of cookies) { - if (wrotePartitioned) { - cookie.remove({ sameSite: 'Lax', secure: getSecureAttribute('Lax'), partitioned: false }); - cookie.remove({ sameSite: 'None', secure: getSecureAttribute('None'), partitioned: false }); - } else { - cookie.remove(partitionedCookieAttributes); - } + cookie.remove({ sameSite: 'Lax', secure: getSecureAttribute('Lax'), partitioned: false }); + cookie.remove({ sameSite: 'None', secure: getSecureAttribute('None'), partitioned: false }); } };