From f99d77f12b8b8297b3c0fb81b916d446ae917db1 Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 11 Mar 2026 14:09:09 -0500 Subject: [PATCH 1/2] fix(backend): exclude non-GET requests from handshake and multi-domain sync eligibility --- .changeset/post-handshake-405-fix.md | 5 ++ .../src/tokens/__tests__/handshake.test.ts | 20 +++++++ .../src/tokens/__tests__/request.test.ts | 58 +++++++++++++++++++ .../backend/src/tokens/authenticateContext.ts | 2 + packages/backend/src/tokens/handshake.ts | 9 ++- packages/backend/src/tokens/request.ts | 4 +- 6 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 .changeset/post-handshake-405-fix.md diff --git a/.changeset/post-handshake-405-fix.md b/.changeset/post-handshake-405-fix.md new file mode 100644 index 00000000000..c7c45a273e8 --- /dev/null +++ b/.changeset/post-handshake-405-fix.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': patch +--- + +Fix POST requests with `sec-fetch-dest: document` incorrectly triggering handshake redirects, resulting in 405 errors from FAPI. Non-GET requests (e.g. native form submissions) are now excluded from handshake and multi-domain sync eligibility. diff --git a/packages/backend/src/tokens/__tests__/handshake.test.ts b/packages/backend/src/tokens/__tests__/handshake.test.ts index 4ee06f80a7b..43b9e430cbb 100644 --- a/packages/backend/src/tokens/__tests__/handshake.test.ts +++ b/packages/backend/src/tokens/__tests__/handshake.test.ts @@ -94,6 +94,7 @@ describe('HandshakeService', () => { clerkUrl: new URL('https://example.com'), frontendApi: 'api.clerk.com', instanceType: 'production', + method: 'GET', usesSuffixedCookies: () => true, secFetchDest: 'document', accept: 'text/html', @@ -139,6 +140,25 @@ describe('HandshakeService', () => { mockAuthenticateContext.accept = 'image/png'; expect(handshakeService.isRequestEligibleForHandshake()).toBe(false); }); + + it('should return false for POST requests with document secFetchDest', () => { + mockAuthenticateContext.method = 'POST'; + mockAuthenticateContext.secFetchDest = 'document'; + expect(handshakeService.isRequestEligibleForHandshake()).toBe(false); + }); + + it('should return false for PUT requests with document secFetchDest', () => { + mockAuthenticateContext.method = 'PUT'; + mockAuthenticateContext.secFetchDest = 'document'; + expect(handshakeService.isRequestEligibleForHandshake()).toBe(false); + }); + + it('should return false for POST requests with text/html accept without secFetchDest', () => { + mockAuthenticateContext.method = 'POST'; + mockAuthenticateContext.secFetchDest = undefined; + mockAuthenticateContext.accept = 'text/html'; + expect(handshakeService.isRequestEligibleForHandshake()).toBe(false); + }); }); describe('buildRedirectToHandshake', () => { diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index 3a9640fe23e..af51c19130d 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -2095,4 +2095,62 @@ describe('tokens.authenticateRequest(options)', () => { }); }); }); + + describe('POST requests with sec-fetch-dest: document', () => { + const mockPostRequest = (headers = {}, cookies = {}, requestUrl = 'http://clerk.com/path') => { + const cookieStr = Object.entries(cookies) + .map(([k, v]) => `${k}=${v}`) + .join(';'); + + return new Request(requestUrl, { + method: 'POST', + headers: { ...defaultHeaders, 'sec-fetch-dest': 'document', cookie: cookieStr, ...headers }, + }); + }; + + test('returns signed out instead of handshake when clientUat > 0 and no cookieToken', async () => { + const requestState = await authenticateRequest( + mockPostRequest({}, { __client_uat: '12345' }), + mockOptions({ secretKey: 'deadbeef', publishableKey: PK_LIVE }), + ); + + expect(requestState).toBeSignedOut({ reason: AuthErrorReason.ClientUATWithoutSessionToken }); + }); + + test('returns signed out instead of handshake for satellite app needing sync', async () => { + const requestState = await authenticateRequest( + mockPostRequest({}, { __client_uat: '0' }), + mockOptions({ + publishableKey: PK_LIVE, + secretKey: 'deadbeef', + isSatellite: true, + signInUrl: 'https://primary.dev/sign-in', + domain: 'satellite.dev', + }), + ); + + expect(requestState).toBeSignedOut({ + reason: AuthErrorReason.SessionTokenAndUATMissing, + isSatellite: true, + signInUrl: 'https://primary.dev/sign-in', + domain: 'satellite.dev', + }); + }); + + test('returns signed out instead of handshake when clientUat > cookieToken.iat', async () => { + const requestState = await authenticateRequest( + mockPostRequest( + {}, + { + __clerk_db_jwt: 'deadbeef', + __client_uat: `${mockJwtPayload.iat + 10}`, + __session: mockJwt, + }, + ), + mockOptions(), + ); + + expect(requestState).toBeSignedOut({ reason: AuthErrorReason.SessionTokenIATBeforeClientUAT }); + }); + }); }); diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index 55c0ed6ad21..19fb89001c0 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -17,6 +17,7 @@ interface AuthenticateContext extends AuthenticateRequestOptions { forwardedHost: string | undefined; forwardedProto: string | undefined; host: string | undefined; + method: string; origin: string | undefined; referrer: string | undefined; secFetchDest: string | undefined; @@ -281,6 +282,7 @@ class AuthenticateContext implements AuthenticateContext { } private initHeaderValues() { + this.method = this.clerkRequest.method; this.tokenInHeader = this.parseAuthorizationHeader(this.getHeader(constants.Headers.Authorization)); this.origin = this.getHeader(constants.Headers.Origin); this.host = this.getHeader(constants.Headers.Host); diff --git a/packages/backend/src/tokens/handshake.ts b/packages/backend/src/tokens/handshake.ts index 8b0b79ee0b0..affeb3b5b0a 100644 --- a/packages/backend/src/tokens/handshake.ts +++ b/packages/backend/src/tokens/handshake.ts @@ -105,7 +105,14 @@ export class HandshakeService { * @returns boolean indicating if the request is eligible for handshake */ isRequestEligibleForHandshake(): boolean { - const { accept, secFetchDest } = this.authenticateContext; + const { accept, method, secFetchDest } = this.authenticateContext; + + // Handshake involves a redirect to FAPI which only accepts GET requests. + // Non-GET requests (e.g. POST form submissions) also set sec-fetch-dest: document, + // but redirecting them would result in a 405 Method Not Allowed from FAPI. + if (method !== 'GET') { + return false; + } // NOTE: we could also check sec-fetch-mode === navigate here, but according to the spec, sec-fetch-dest: document should indicate that the request is the data of a user navigation. // Also, we check for 'iframe' because it's the value set when a doc request is made by an iframe. diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 59c8f12393b..3a13cc538ef 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -475,7 +475,9 @@ export const authenticateRequest: AuthenticateRequest = (async ( } } const isRequestEligibleForMultiDomainSync = - authenticateContext.isSatellite && authenticateContext.secFetchDest === 'document'; + authenticateContext.isSatellite && + authenticateContext.secFetchDest === 'document' && + authenticateContext.method === 'GET'; /** * Begin multi-domain sync flows From 01b0d2273bc8314ee508893a08efdd04e6bb0122 Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 11 Mar 2026 14:42:07 -0500 Subject: [PATCH 2/2] fix(backend): guard primary cross-origin sync handshake against non-GET requests --- .../src/tokens/__tests__/request.test.ts | 33 +++++++++++++++++++ packages/backend/src/tokens/request.ts | 1 + 2 files changed, 34 insertions(+) diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index af51c19130d..a6a0d12acb3 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -1815,6 +1815,39 @@ describe('tokens.authenticateRequest(options)', () => { }); }); + test('does not trigger handshake for cross-origin POST document request on primary domain', async () => { + const cookieStr = Object.entries({ + __session: mockJwt, + __client_uat: '12345', + }) + .map(([k, v]) => `${k}=${v}`) + .join(';'); + + const request = new Request('https://primary.com/dashboard', { + method: 'POST', + headers: { + ...defaultHeaders, + referer: 'https://satellite.com/form', + 'sec-fetch-dest': 'document', + cookie: cookieStr, + }, + }); + + const requestState = await authenticateRequest(request, { + ...mockOptions(), + publishableKey: PK_LIVE, + domain: 'primary.com', + isSatellite: false, + signInUrl: 'https://primary.com/sign-in', + }); + + expect(requestState).toBeSignedIn({ + domain: 'primary.com', + isSatellite: false, + signInUrl: 'https://primary.com/sign-in', + }); + }); + test('does not trigger handshake for non-document requests', async () => { const request = mockRequestWithCookies( { diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 3a13cc538ef..2be6fc8d3e7 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -652,6 +652,7 @@ export const authenticateRequest: AuthenticateRequest = (async ( // Check for cross-origin requests from satellite domains to primary domain const shouldForceHandshakeForCrossDomain = !authenticateContext.isSatellite && // We're on primary + authenticateContext.method === 'GET' && // Only GET navigations (POST form submissions set sec-fetch-dest: document too) authenticateContext.secFetchDest === 'document' && // Document navigation authenticateContext.isCrossOriginReferrer() && // Came from different domain !authenticateContext.isKnownClerkReferrer() && // Not from Clerk accounts portal or FAPI