From 67c3fdc7173248895f2cd579f4b48213d35d9f37 Mon Sep 17 00:00:00 2001 From: Dominic Couture Date: Mon, 13 Jul 2026 16:07:07 +0200 Subject: [PATCH 1/2] fix(backend): Fix cross-origin handshake bypass Fix a cross-origin handshake bypass where `isKnownClerkReferrer()` trusted overly broad referrer hosts as Clerk-owned: any `accounts.*` host (e.g. `accounts.attacker.com`), plus dev account-portal domains (`*.accounts.dev` and legacy suffixes) on production instances. These let unrelated origins skip the handshake and its session-freshness check. The referrer is now trusted only for the accounts portal derived from the instance's frontend API, plus dev account-portal domains on non-production instances. --- .changeset/khaki-chairs-kiss.md | 5 ++ .../src/tokens/__tests__/request.test.ts | 68 ++++++++++++++++--- .../backend/src/tokens/authenticateContext.ts | 15 ++-- 3 files changed, 72 insertions(+), 16 deletions(-) create mode 100644 .changeset/khaki-chairs-kiss.md diff --git a/.changeset/khaki-chairs-kiss.md b/.changeset/khaki-chairs-kiss.md new file mode 100644 index 00000000000..ff01e897fd2 --- /dev/null +++ b/.changeset/khaki-chairs-kiss.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': minor +--- + +Fix a cross-origin handshake bypass where `isKnownClerkReferrer()` trusted overly broad referrer hosts as Clerk-owned: any `accounts.*` host (e.g. `accounts.attacker.com`), plus dev account-portal domains (`*.accounts.dev` and legacy suffixes) on production instances. These let unrelated origins skip the handshake and its session-freshness check. The referrer is now trusted only for the accounts portal derived from the instance's frontend API, plus dev account-portal domains on non-production instances. diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index f2ab2b5a663..82895c43b24 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -2058,7 +2058,8 @@ describe('tokens.authenticateRequest(options)', () => { }); }); - test('does not trigger handshake when referer is from production accounts portal', async () => { + // accounts.example.com is not this instance's derived accounts portal, so it must not be trusted. + test('triggers handshake when referer is an unrelated accounts.* domain', async () => { const request = mockRequestWithCookies( { referer: 'https://accounts.example.com/sign-in', @@ -2080,14 +2081,65 @@ describe('tokens.authenticateRequest(options)', () => { signInUrl: 'https://primary.com/sign-in', }); - expect(requestState).toBeSignedIn({ + expect(requestState).toMatchHandshake({ + reason: AuthErrorReason.PrimaryDomainCrossOriginSync, + domain: 'primary.com', + signInUrl: 'https://primary.com/sign-in', + }); + }); + + test('does not trigger cross-origin handshake when referer is from dev accounts portal on a dev instance (current format)', async () => { + const request = mockRequestWithCookies( + { + referer: 'https://foo-bar-13.accounts.dev/sign-in', + 'sec-fetch-dest': 'document', + 'sec-fetch-site': 'cross-site', + }, + { + __session: mockJwt, + __client_uat: '12345', + }, + 'https://primary.com/dashboard', + ); + + const requestState = await authenticateRequest(request, { + ...mockOptions(), + publishableKey: PK_TEST, + domain: 'primary.com', + isSatellite: false, + signInUrl: 'https://primary.com/sign-in', + }); + + expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync); + }); + + test('does not trigger cross-origin handshake when referer is from dev accounts portal on a dev instance (legacy format)', async () => { + const request = mockRequestWithCookies( + { + referer: 'https://accounts.foo-bar-13.lcl.dev/sign-in', + 'sec-fetch-dest': 'document', + 'sec-fetch-site': 'cross-site', + }, + { + __session: mockJwt, + __client_uat: '12345', + }, + 'https://primary.com/dashboard', + ); + + const requestState = await authenticateRequest(request, { + ...mockOptions(), + publishableKey: PK_TEST, domain: 'primary.com', isSatellite: false, signInUrl: 'https://primary.com/sign-in', }); + + expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync); }); - test('does not trigger handshake when referer is from dev accounts portal (current format)', async () => { + // A production instance must not trust dev-portal referrers, which are freely obtainable. + test('triggers handshake when referer is a dev accounts portal on a production instance (current format)', async () => { const request = mockRequestWithCookies( { referer: 'https://foo-bar-13.accounts.dev/sign-in', @@ -2109,14 +2161,14 @@ describe('tokens.authenticateRequest(options)', () => { signInUrl: 'https://primary.com/sign-in', }); - expect(requestState).toBeSignedIn({ + expect(requestState).toMatchHandshake({ + reason: AuthErrorReason.PrimaryDomainCrossOriginSync, domain: 'primary.com', - isSatellite: false, signInUrl: 'https://primary.com/sign-in', }); }); - test('does not trigger handshake when referer is from dev accounts portal (legacy format)', async () => { + test('triggers handshake when referer is a dev accounts portal on a production instance (legacy format)', async () => { const request = mockRequestWithCookies( { referer: 'https://accounts.foo-bar-13.lcl.dev/sign-in', @@ -2138,9 +2190,9 @@ describe('tokens.authenticateRequest(options)', () => { signInUrl: 'https://primary.com/sign-in', }); - expect(requestState).toBeSignedIn({ + expect(requestState).toMatchHandshake({ + reason: AuthErrorReason.PrimaryDomainCrossOriginSync, domain: 'primary.com', - isSatellite: false, signInUrl: 'https://primary.com/sign-in', }); }); diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index e8d1d682330..3cb727d62f9 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -239,12 +239,16 @@ class AuthenticateContext implements AuthenticateContext { } } - // Check for development account portal patterns - if (isLegacyDevAccountPortalOrigin(referrerHost) || isCurrentDevAccountPortalOrigin(referrerHost)) { + // Dev account-portal domains are freely obtainable, so only trust them on non-production instances. + if ( + this.instanceType !== 'production' && + (isLegacyDevAccountPortalOrigin(referrerHost) || isCurrentDevAccountPortalOrigin(referrerHost)) + ) { return true; } - // Check for production account portal by comparing with expected accounts URL + // Only trust the accounts portal derived from this instance's frontend API — never a + // generic `accounts.*` prefix, which any attacker-controlled domain could match. const expectedAccountsUrl = buildAccountsBaseUrl(this.frontendApi); if (expectedAccountsUrl) { const expectedAccountsOrigin = new URL(expectedAccountsUrl).origin; @@ -253,11 +257,6 @@ class AuthenticateContext implements AuthenticateContext { } } - // Check for generic production accounts patterns (accounts.*) - if (referrerHost.startsWith('accounts.')) { - return true; - } - return false; } catch { // Invalid URL format From 9bee8e17383679f4242d510ffbfde651b3ab0778 Mon Sep 17 00:00:00 2001 From: Dominic Couture Date: Mon, 13 Jul 2026 16:45:51 +0200 Subject: [PATCH 2/2] Address review comments --- .../backend/src/tokens/__tests__/request.test.ts | 14 ++++++++++++-- packages/backend/src/tokens/authenticateContext.ts | 8 ++++---- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index 82895c43b24..b086ae54aa2 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -2098,6 +2098,7 @@ describe('tokens.authenticateRequest(options)', () => { { __session: mockJwt, __client_uat: '12345', + __clerk_db_jwt: mockJwt, }, 'https://primary.com/dashboard', ); @@ -2110,7 +2111,11 @@ describe('tokens.authenticateRequest(options)', () => { signInUrl: 'https://primary.com/sign-in', }); - expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync); + expect(requestState).toBeSignedIn({ + domain: 'primary.com', + isSatellite: false, + signInUrl: 'https://primary.com/sign-in', + }); }); test('does not trigger cross-origin handshake when referer is from dev accounts portal on a dev instance (legacy format)', async () => { @@ -2123,6 +2128,7 @@ describe('tokens.authenticateRequest(options)', () => { { __session: mockJwt, __client_uat: '12345', + __clerk_db_jwt: mockJwt, }, 'https://primary.com/dashboard', ); @@ -2135,7 +2141,11 @@ describe('tokens.authenticateRequest(options)', () => { signInUrl: 'https://primary.com/sign-in', }); - expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync); + expect(requestState).toBeSignedIn({ + domain: 'primary.com', + isSatellite: false, + signInUrl: 'https://primary.com/sign-in', + }); }); // A production instance must not trust dev-portal referrers, which are freely obtainable. diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index 3cb727d62f9..aaf1d8f7e04 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -216,11 +216,11 @@ class AuthenticateContext implements AuthenticateContext { } /** - * Determines if the referrer URL is from a Clerk domain (accounts portal or FAPI). - * This includes both development and production account portal domains, as well as FAPI domains - * used for redirect-based authentication flows. + * Determines if the referrer URL is from a Clerk domain: the instance's FAPI domain, the accounts + * portal derived from its frontend API, or — on non-production instances only — a development + * account-portal domain. * - * @returns {boolean} True if the referrer is from a Clerk accounts portal or FAPI domain, false otherwise + * @returns {boolean} True if the referrer is a trusted Clerk domain, false otherwise */ public isKnownClerkReferrer(): boolean { if (!this.referrer) {