From ec49381997226e92b0ce846802cdbd59fd5e89d0 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Fri, 6 Mar 2026 00:32:40 +0200 Subject: [PATCH 1/6] fix(backend,clerk-js): treat undefined satelliteAutoSync as false (Core 3 default) The JSDoc on satelliteAutoSync says @default false, and the Core 3 upgrade codemod adds satelliteAutoSync={true} to existing satellite configs, but the runtime check used === false (strict equality). This meant undefined (not passing the prop) behaved like true, preserving Core 2 auto-sync behavior instead of the intended Core 3 default of no auto-sync. Change the check from === false to !== true so that undefined is treated the same as false, matching the documented default. --- integration/tests/handshake.test.ts | 36 +++++--- .../src/tokens/__tests__/request.test.ts | 84 ++++++++++++++++++- packages/backend/src/tokens/request.ts | 6 +- packages/clerk-js/src/core/clerk.ts | 7 +- 4 files changed, 114 insertions(+), 19 deletions(-) diff --git a/integration/tests/handshake.test.ts b/integration/tests/handshake.test.ts index dc6975fc524..e98b9c52ed2 100644 --- a/integration/tests/handshake.test.ts +++ b/integration/tests/handshake.test.ts @@ -529,7 +529,7 @@ test.describe('Client handshake @generic', () => { expect(res.status).toBe(200); }); - test('signed out satellite with sec-fetch-dest=document - prod', async () => { + test('signed out satellite with sec-fetch-dest=document skips handshake by default (satelliteAutoSync unset) - prod', async () => { const config = generateConfig({ mode: 'live', }); @@ -543,13 +543,8 @@ test.describe('Client handshake @generic', () => { }), redirect: 'manual', }); - expect(res.status).toBe(307); - const locationUrl = new URL(res.headers.get('location')); - expect(locationUrl.origin + locationUrl.pathname).toBe('https://clerk.example.com/v1/client/handshake'); - expect(locationUrl.searchParams.get('redirect_url')).toBe(`${app.serverUrl}/`); - expect(locationUrl.searchParams.get('__clerk_hs_reason')).toBe('satellite-needs-syncing'); - expect(locationUrl.searchParams.has('__clerk_api_version')).toBe(true); - expect(locationUrl.searchParams.get('suffixed_cookies')).toBe('false'); + // In Core 3, satelliteAutoSync defaults to false, so no handshake redirect + expect(res.status).toBe(200); }); test('signed out satellite - dev', async () => { @@ -628,7 +623,28 @@ test.describe('Client handshake @generic', () => { expect(res.status).toBe(200); }); - test('signed out satellite with satelliteAutoSync=true (default) triggers handshake - prod', async () => { + test('signed out satellite with satelliteAutoSync unset triggers handshake when __clerk_synced=false - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const res = await fetch(app.serverUrl + '/?__clerk_synced=false', { + headers: new Headers({ + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + 'X-Satellite': 'true', + 'X-Domain': 'example.com', + 'Sec-Fetch-Dest': 'document', + }), + redirect: 'manual', + }); + // Even without satelliteAutoSync, __clerk_synced=false (post sign-in) should trigger handshake + expect(res.status).toBe(307); + const locationUrl = new URL(res.headers.get('location')); + expect(locationUrl.origin + locationUrl.pathname).toBe('https://clerk.example.com/v1/client/handshake'); + expect(locationUrl.searchParams.get('__clerk_hs_reason')).toBe('satellite-needs-syncing'); + }); + + test('signed out satellite with satelliteAutoSync=true (explicit opt-in) triggers handshake - prod', async () => { const config = generateConfig({ mode: 'live', }); @@ -643,7 +659,7 @@ test.describe('Client handshake @generic', () => { }), redirect: 'manual', }); - // Should redirect to handshake with default/true satelliteAutoSync + // Should redirect to handshake when satelliteAutoSync is explicitly true expect(res.status).toBe(307); const locationUrl = new URL(res.headers.get('location')); expect(locationUrl.origin + locationUrl.pathname).toBe('https://clerk.example.com/v1/client/handshake'); diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index 3a9640fe23e..2c4d717cd05 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -651,7 +651,7 @@ describe('tokens.authenticateRequest(options)', () => { expect(requestState).toBeSignedOutToAuth(); }); - test('cookieToken: returns handshake when clientUat is missing or equals to 0 and is satellite and not is synced [11y]', async () => { + test('cookieToken: returns handshake when clientUat is missing or equals to 0 and is satellite with satelliteAutoSync=true and not is synced [11y]', async () => { server.use( http.get('https://api.clerk.test/v1/jwks', () => { return HttpResponse.json(mockJwks); @@ -671,6 +671,7 @@ describe('tokens.authenticateRequest(options)', () => { isSatellite: true, signInUrl: 'https://primary.dev/sign-in', domain: 'satellite.dev', + satelliteAutoSync: true, }), ); @@ -684,7 +685,7 @@ describe('tokens.authenticateRequest(options)', () => { expect(requestState.toAuth()).toBeNull(); }); - test('cookieToken: redirects to signInUrl when is satellite dev and not synced', async () => { + test('cookieToken: redirects to signInUrl when is satellite dev with satelliteAutoSync=true and not synced', async () => { server.use( http.get('https://api.clerk.test/v1/jwks', () => { return HttpResponse.json(mockJwks); @@ -705,6 +706,7 @@ describe('tokens.authenticateRequest(options)', () => { isSatellite: true, signInUrl: 'https://primary.dev/sign-in', domain: 'satellite.dev', + satelliteAutoSync: true, }), ); @@ -873,6 +875,84 @@ describe('tokens.authenticateRequest(options)', () => { expect(requestState.toAuth()).toBeSignedOutToAuth(); }); + test('cookieToken: returns signed out without handshake when satelliteAutoSync is not set (defaults to false) and no cookies - prod', async () => { + const requestState = await authenticateRequest( + mockRequestWithCookies( + { ...defaultHeaders, 'sec-fetch-dest': 'document' }, + { __client_uat: '0' }, + `http://satellite.example/path`, + ), + mockOptions({ + secretKey: 'deadbeef', + publishableKey: PK_LIVE, + signInUrl: 'https://primary.example/sign-in', + isSatellite: true, + domain: 'satellite.example', + }), + ); + + expect(requestState).toBeSignedOut({ + reason: AuthErrorReason.SessionTokenAndUATMissing, + isSatellite: true, + domain: 'satellite.example', + signInUrl: 'https://primary.example/sign-in', + }); + expect(requestState.toAuth()).toBeSignedOutToAuth(); + expect(requestState.headers.get('location')).toBeNull(); + }); + + test('cookieToken: returns signed out without handshake when satelliteAutoSync is not set (defaults to false) and no cookies - dev', async () => { + const requestState = await authenticateRequest( + mockRequestWithCookies( + { ...defaultHeaders, 'sec-fetch-dest': 'document' }, + { + __client_uat: '0', + __clerk_db_jwt: mockJwt, + }, + ), + mockOptions({ + secretKey: 'sk_test_deadbeef', + publishableKey: PK_TEST, + isSatellite: true, + signInUrl: 'https://primary.dev/sign-in', + domain: 'satellite.dev', + }), + ); + + expect(requestState).toBeSignedOut({ + reason: AuthErrorReason.SessionTokenAndUATMissing, + isSatellite: true, + domain: 'satellite.dev', + signInUrl: 'https://primary.dev/sign-in', + }); + expect(requestState.toAuth()).toBeSignedOutToAuth(); + expect(requestState.headers.get('location')).toBeNull(); + }); + + test('cookieToken: triggers handshake when satelliteAutoSync is not set but __clerk_synced=false is present - prod', async () => { + const requestState = await authenticateRequest( + mockRequestWithCookies( + { ...defaultHeaders, 'sec-fetch-dest': 'document' }, + { __client_uat: '0' }, + `http://satellite.example/path?__clerk_synced=false`, + ), + mockOptions({ + secretKey: 'deadbeef', + publishableKey: PK_LIVE, + signInUrl: 'https://primary.example/sign-in', + isSatellite: true, + domain: 'satellite.example', + }), + ); + + expect(requestState).toMatchHandshake({ + reason: AuthErrorReason.SatelliteCookieNeedsSyncing, + isSatellite: true, + domain: 'satellite.example', + signInUrl: 'https://primary.example/sign-in', + }); + }); + test('cookieToken: returns handshake when app is not satellite and responds to syncing on dev instances[12y]', async () => { const sp = new URLSearchParams(); sp.set('__clerk_redirect_url', 'http://localhost:3000'); diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 59c8f12393b..dece921add6 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -484,7 +484,7 @@ export const authenticateRequest: AuthenticateRequest = (async ( * - 'false' (NeedsSync): Trigger sync - satellite returning from primary sign-in * - 'true' (Completed): Sync done - prevents re-sync loop * - * With satelliteAutoSync=false: + * With satelliteAutoSync=false or unset (Core 3 default): * - Skip handshake on first visit if no cookies exist (return signedOut immediately) * - Trigger handshake when __clerk_synced=false is present (post sign-in redirect) * - Allow normal token verification flow when cookies exist (enables refresh) @@ -499,8 +499,8 @@ export const authenticateRequest: AuthenticateRequest = (async ( const hasCookies = hasSessionToken || hasActiveClient; // Determine if we should skip handshake for satellites with no cookies - // satelliteAutoSync defaults to true, so we only skip when explicitly set to false - const shouldSkipSatelliteHandshake = authenticateContext.satelliteAutoSync === false && !hasCookies && !needsSync; + // satelliteAutoSync defaults to false (Core 3), so we skip unless explicitly set to true + const shouldSkipSatelliteHandshake = authenticateContext.satelliteAutoSync !== true && !hasCookies && !needsSync; if (authenticateContext.instanceType === 'production' && isRequestEligibleForMultiDomainSync && !syncCompleted) { // With satelliteAutoSync=false: skip handshake if no cookies and no sync trigger diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index b6ac5321a41..b8dc54ff8b3 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -2845,10 +2845,9 @@ export class Clerk implements ClerkInterface { return true; } - // Check if satelliteAutoSync is disabled - if so, skip automatic sync - // unless explicitly triggered via __clerk_synced=false - if (this.#options.satelliteAutoSync === false) { - // Skip automatic sync when satelliteAutoSync is false + // Check if satelliteAutoSync is enabled - only auto-sync when explicitly opted in + // In Core 3, satelliteAutoSync defaults to false (undefined is treated as false) + if (this.#options.satelliteAutoSync !== true) { return false; } From dd6e2883cf9a2c5108e602271630e8b7eabeff6f Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Fri, 6 Mar 2026 00:33:16 +0200 Subject: [PATCH 2/6] chore: add changeset --- .changeset/fix-satellite-auto-sync-default.md | 6 ++++ .../src/tokens/__tests__/request.test.ts | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 .changeset/fix-satellite-auto-sync-default.md diff --git a/.changeset/fix-satellite-auto-sync-default.md b/.changeset/fix-satellite-auto-sync-default.md new file mode 100644 index 00000000000..74a71046f36 --- /dev/null +++ b/.changeset/fix-satellite-auto-sync-default.md @@ -0,0 +1,6 @@ +--- +"@clerk/backend": patch +"@clerk/clerk-js": patch +--- + +Fix `satelliteAutoSync` to default to `false` as documented. Previously, not passing the prop resulted in `undefined`, which was treated as `true` due to a strict equality check (`=== false`). This preserved Core 2 auto-sync behavior instead of the intended Core 3 default. The check is now `!== true`, so both `undefined` and `false` skip automatic satellite sync. diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index 2c4d717cd05..d5259358697 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -953,6 +953,36 @@ describe('tokens.authenticateRequest(options)', () => { }); }); + test('cookieToken: triggers handshake when satelliteAutoSync is not set but __clerk_synced=false is present - dev', async () => { + const requestState = await authenticateRequest( + mockRequestWithCookies( + { ...defaultHeaders, 'sec-fetch-dest': 'document' }, + { + __client_uat: '0', + __clerk_db_jwt: mockJwt, + }, + `http://satellite.dev/path?__clerk_synced=false`, + ), + mockOptions({ + secretKey: 'sk_test_deadbeef', + publishableKey: PK_TEST, + signInUrl: 'https://primary.dev/sign-in', + isSatellite: true, + domain: 'satellite.dev', + }), + ); + + expect(requestState).toMatchHandshake({ + reason: AuthErrorReason.SatelliteCookieNeedsSyncing, + isSatellite: true, + domain: 'satellite.dev', + signInUrl: 'https://primary.dev/sign-in', + }); + expect(requestState.headers.get('location')).toEqual( + `https://primary.dev/sign-in?__clerk_redirect_url=http%3A%2F%2Fexample.com%2Fpath%3F__clerk_synced%3Dfalse`, + ); + }); + test('cookieToken: returns handshake when app is not satellite and responds to syncing on dev instances[12y]', async () => { const sp = new URLSearchParams(); sp.set('__clerk_redirect_url', 'http://localhost:3000'); From 90fed2d0e6f9dd888c53718bd2dcc774b4301d22 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Fri, 6 Mar 2026 01:21:08 +0200 Subject: [PATCH 3/6] fix(clerk-js): fix inverted isSignedOut() semantics in AuthCookieService The post-load branch of isSignedOut() returned !!this.clerk.user, which is true when the user IS signed in. This is inverted from what the method name implies. Currently dead code (the only caller runs before clerk.loaded is true), but fixing it prevents a latent bug where signed-in satellite users with satelliteAutoSync=true would get bounced to primary unnecessarily if this method were ever called post-load. --- 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 a20a3cd4635..cba87b725cd 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -111,7 +111,7 @@ export class AuthCookieService { if (!this.clerk.loaded) { return this.clientUat.get() <= 0; } - return !!this.clerk.user; + return !this.clerk.user; } public async handleUnauthenticatedDevBrowser() { From ea6fb15605d4b2bfdbefb61bbc2b371eb51d8ace Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Fri, 6 Mar 2026 09:22:54 +0200 Subject: [PATCH 4/6] fix(clerk-js): add Client.fetch mock for satellite getter tests These tests reset mockClientFetch in their beforeEach but didn't set up a return value. Previously this was fine because #shouldSyncWithPrimary() returned true and the code never reached Client.fetch(). Now that satelliteAutoSync defaults to false, the code falls through to the client fetch, so a mock is needed. --- packages/clerk-js/src/core/__tests__/clerk.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 4d9fb15bc5b..7483aa17102 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -2386,6 +2386,7 @@ describe('Clerk singleton', () => { describe('Clerk().isSatellite and Clerk().domain getters', () => { beforeEach(() => { mockClientFetch.mockReset(); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [] })); mockEnvironmentFetch.mockReturnValue( Promise.resolve({ authConfig: {}, From 996d1f9bc9c852ee65e0164f53d80c9dd19c939d Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Fri, 6 Mar 2026 09:41:16 +0200 Subject: [PATCH 5/6] chore: retrigger CI From e664eb16bb422c65c1af937692f2455bc489686b Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Fri, 6 Mar 2026 09:45:12 +0200 Subject: [PATCH 6/6] chore: trigger CI after marking PR ready