From b11c80247c8fc16db1bb8a4f73dabb2e4771be04 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 1 Sep 2026 19:52:29 -0700 Subject: [PATCH 1/9] fix(backend): Add __internal_resolveHandshakeOnlyForNavigation option Research spike: a backend-level counterpart to fastify's shipped __internal_enableHandshake, under a distinct name so the two are not confused. When true, handshake payload resolution is skipped for requests that are not eligible for a handshake redirect (non-GET, fetch/XHR), so a stale nonce no longer triggers a failing Backend API call per request. Navigation requests still resolve and redirect, so dev instances keep working. Includes an express integration test against the real backend proving the unchanged middleware forwards the option. Claude-Session: https://claude.ai/code/session_014TUNghY1SiahtRY5t1zZWC --- .../backend-internal-enable-handshake.md | 5 + .../express-handshake-opt-out-coverage.md | 2 + .../__tests__/request.handshakeOptOut.test.ts | 219 ++++++++++++++++++ packages/backend/src/tokens/request.ts | 9 +- packages/backend/src/tokens/types.ts | 12 + .../clerkMiddleware.handshakeOptOut.test.ts | 90 +++++++ 6 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 .changeset/backend-internal-enable-handshake.md create mode 100644 .changeset/express-handshake-opt-out-coverage.md create mode 100644 packages/backend/src/tokens/__tests__/request.handshakeOptOut.test.ts create mode 100644 packages/express/src/__tests__/clerkMiddleware.handshakeOptOut.test.ts diff --git a/.changeset/backend-internal-enable-handshake.md b/.changeset/backend-internal-enable-handshake.md new file mode 100644 index 00000000000..6a3f403ae7c --- /dev/null +++ b/.changeset/backend-internal-enable-handshake.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': patch +--- + +Add an internal `__internal_resolveHandshakeOnlyForNavigation` option to `authenticateRequest()` (defaults to `false`). When set to `true`, handshake cookies and query params are ignored on requests that cannot complete a handshake redirect (non-GET requests and `fetch`/XHR calls), so a stale handshake nonce no longer triggers a failing Backend API call on every request. Navigation requests are unaffected. Intended for API-only backends that cannot return `Set-Cookie` headers to the browser. diff --git a/.changeset/express-handshake-opt-out-coverage.md b/.changeset/express-handshake-opt-out-coverage.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/express-handshake-opt-out-coverage.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/backend/src/tokens/__tests__/request.handshakeOptOut.test.ts b/packages/backend/src/tokens/__tests__/request.handshakeOptOut.test.ts new file mode 100644 index 00000000000..8e120d2c3b3 --- /dev/null +++ b/packages/backend/src/tokens/__tests__/request.handshakeOptOut.test.ts @@ -0,0 +1,219 @@ +import { http, HttpResponse } from 'msw'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { mockJwks, mockJwt, mockJwtPayload } from '../../fixtures'; +import { server } from '../../mock-server'; +import { AuthErrorReason, AuthStatus } from '../authStatus'; +import { HandshakeService } from '../handshake'; +import { authenticateRequest } from '../request'; +import type { AuthenticateRequestOptions } from '../types'; + +const PK_TEST = 'pk_test_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA'; +const PK_LIVE = 'pk_live_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA'; + +const navigationHeaders = { host: 'example.com', 'user-agent': 'Mozilla/TestAgent', 'sec-fetch-dest': 'document' }; +const fetchHeaders = { + host: 'example.com', + 'user-agent': 'Mozilla/TestAgent', + 'sec-fetch-dest': 'empty', + accept: '*/*', +}; + +const requestWith = ( + headers: Record, + cookies: Record = {}, + { method = 'GET', url = 'http://example.com/api/me' } = {}, +) => { + const cookie = Object.entries(cookies) + .map(([k, v]) => `${k}=${v}`) + .join(';'); + return new Request(url, { method, headers: { ...headers, cookie } }); +}; + +const buildOptions = (overrides: Partial = {}) => { + const getHandshakePayload = vi.fn().mockResolvedValue({ directives: [`__session=${mockJwt}; Path=/`] }); + const options = { + secretKey: 'live_deadbeef', + apiUrl: 'https://api.clerk.test', + apiVersion: 'v1', + publishableKey: PK_LIVE, + proxyUrl: '', + skipJwksCache: true, + isSatellite: false, + signInUrl: '', + signUpUrl: '', + afterSignInUrl: '', + afterSignUpUrl: '', + domain: '', + apiClient: { clients: { getHandshakePayload } }, + ...overrides, + } as unknown as AuthenticateRequestOptions; + return { options, getHandshakePayload }; +}; + +describe('authenticateRequest with __internal_resolveHandshakeOnlyForNavigation: true', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(mockJwtPayload.iat * 1000)); + server.use(http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks))); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + test('fetch request with a stale nonce cookie skips the payload exchange and authenticates from the session cookie', async () => { + const { options, getHandshakePayload } = buildOptions({ __internal_resolveHandshakeOnlyForNavigation: true }); + + const state = await authenticateRequest( + requestWith(fetchHeaders, { __clerk_handshake_nonce: 'stale', __client_uat: '12345', __session: mockJwt }), + options, + ); + + expect(getHandshakePayload).not.toHaveBeenCalled(); + expect(state.status).toBe(AuthStatus.SignedIn); + expect(state.headers.get('location')).toBeNull(); + }); + + test('POST request with a stale nonce cookie skips the payload exchange', async () => { + const { options, getHandshakePayload } = buildOptions({ __internal_resolveHandshakeOnlyForNavigation: true }); + + const state = await authenticateRequest( + requestWith( + navigationHeaders, + { __clerk_handshake_nonce: 'stale', __client_uat: '12345', __session: mockJwt }, + { method: 'POST' }, + ), + options, + ); + + expect(getHandshakePayload).not.toHaveBeenCalled(); + expect(state.status).toBe(AuthStatus.SignedIn); + }); + + test('fetch request with a stale nonce cookie and no session is signed out without a payload exchange', async () => { + const { options, getHandshakePayload } = buildOptions({ __internal_resolveHandshakeOnlyForNavigation: true }); + + const state = await authenticateRequest( + requestWith(fetchHeaders, { __clerk_handshake_nonce: 'stale', __client_uat: '12345' }), + options, + ); + + expect(getHandshakePayload).not.toHaveBeenCalled(); + expect(state.status).toBe(AuthStatus.SignedOut); + expect(state.reason).toBe(AuthErrorReason.ClientUATWithoutSessionToken); + expect(state.headers.get('location')).toBeNull(); + }); + + test('navigation request with a nonce cookie still exchanges the payload', async () => { + const { options, getHandshakePayload } = buildOptions({ __internal_resolveHandshakeOnlyForNavigation: true }); + + const state = await authenticateRequest( + requestWith(navigationHeaders, { __clerk_handshake_nonce: 'fresh', __client_uat: '12345' }), + options, + ); + + expect(getHandshakePayload).toHaveBeenCalledTimes(1); + expect(getHandshakePayload).toHaveBeenCalledWith({ nonce: 'fresh' }); + expect(state.status).toBe(AuthStatus.SignedIn); + }); + + test('by default a fetch request with a nonce cookie still exchanges the payload', async () => { + const { options, getHandshakePayload } = buildOptions(); + + const state = await authenticateRequest( + requestWith(fetchHeaders, { __clerk_handshake_nonce: 'fresh', __client_uat: '12345' }), + options, + ); + + expect(getHandshakePayload).toHaveBeenCalledTimes(1); + expect(state.status).toBe(AuthStatus.SignedIn); + }); + + test('development instance still redirects a navigation request to the dev browser handshake', async () => { + const { options, getHandshakePayload } = buildOptions({ + __internal_resolveHandshakeOnlyForNavigation: true, + publishableKey: PK_TEST, + secretKey: 'test_deadbeef', + }); + + const state = await authenticateRequest(requestWith(navigationHeaders), options); + + expect(getHandshakePayload).not.toHaveBeenCalled(); + expect(state.status).toBe(AuthStatus.Handshake); + expect(state.reason).toBe(AuthErrorReason.DevBrowserMissing); + expect(state.headers.get('location')).toContain('/v1/client/handshake'); + }); + + test('development navigation request returning from the handshake still resolves the nonce', async () => { + const { options, getHandshakePayload } = buildOptions({ + __internal_resolveHandshakeOnlyForNavigation: true, + publishableKey: PK_TEST, + secretKey: 'test_deadbeef', + }); + + const state = await authenticateRequest( + requestWith(navigationHeaders, { __clerk_handshake_nonce: 'fresh' }), + options, + ); + + expect(getHandshakePayload).toHaveBeenCalledTimes(1); + expect(state.status).toBe(AuthStatus.SignedIn); + }); + + test('stale cookie-transport handshake token on a fetch request is ignored without logging', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { options } = buildOptions({ __internal_resolveHandshakeOnlyForNavigation: true }); + + const state = await authenticateRequest( + requestWith(fetchHeaders, { __clerk_handshake: 'not-a-jwt', __client_uat: '12345', __session: mockJwt }), + options, + ); + + expect(errorSpy).not.toHaveBeenCalled(); + expect(state.status).toBe(AuthStatus.SignedIn); + }); + + test.each([ + ['omitted', {}], + ['explicitly false', { __internal_resolveHandshakeOnlyForNavigation: false }], + ])('when the option is %s the eligibility check is never consulted and the payload is exchanged', async (_, flag) => { + const eligibilitySpy = vi.spyOn(HandshakeService.prototype, 'isRequestEligibleForHandshake'); + const { options, getHandshakePayload } = buildOptions(flag); + + await authenticateRequest( + requestWith(fetchHeaders, { __clerk_handshake_nonce: 'fresh', __client_uat: '12345' }), + options, + ); + + expect(eligibilitySpy).not.toHaveBeenCalled(); + expect(getHandshakePayload).toHaveBeenCalledTimes(1); + }); + + test('only an explicit true consults the eligibility check', async () => { + const eligibilitySpy = vi.spyOn(HandshakeService.prototype, 'isRequestEligibleForHandshake'); + const { options, getHandshakePayload } = buildOptions({ __internal_resolveHandshakeOnlyForNavigation: true }); + + await authenticateRequest( + requestWith(fetchHeaders, { __clerk_handshake_nonce: 'fresh', __client_uat: '12345' }), + options, + ); + + expect(eligibilitySpy).toHaveBeenCalled(); + expect(getHandshakePayload).not.toHaveBeenCalled(); + }); + + test('by default a stale cookie-transport handshake token on a fetch request logs a resolution error', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { options } = buildOptions(); + + const state = await authenticateRequest( + requestWith(fetchHeaders, { __clerk_handshake: 'not-a-jwt', __client_uat: '12345', __session: mockJwt }), + options, + ); + + expect(errorSpy).toHaveBeenCalledWith('Clerk: unable to resolve handshake:', expect.anything()); + expect(state.status).toBe(AuthStatus.SignedIn); + }); +}); diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 07f16f0a97e..f4eef5f0222 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -462,7 +462,14 @@ export const authenticateRequest: AuthenticateRequest = (async ( /** * If we have a handshakeToken, resolve the handshake and attempt to return a definitive signed in or signed out state. */ - if (authenticateContext.handshakeNonce || authenticateContext.handshakeToken) { + const hasHandshakeArtifacts = !!(authenticateContext.handshakeNonce || authenticateContext.handshakeToken); + // When resolution is limited to navigation requests, a stale handshake cookie on a fetch/XHR request must not trigger a + // (failing) payload exchange. Navigation requests still resolve so the redirect flow can complete. + const shouldResolveHandshake = + hasHandshakeArtifacts && + (authenticateContext.__internal_resolveHandshakeOnlyForNavigation !== true || + handshakeService.isRequestEligibleForHandshake()); + if (shouldResolveHandshake) { try { return await handshakeService.resolveHandshake(); } catch (error) { diff --git a/packages/backend/src/tokens/types.ts b/packages/backend/src/tokens/types.ts index 823503a4aba..4168843fdf6 100644 --- a/packages/backend/src/tokens/types.ts +++ b/packages/backend/src/tokens/types.ts @@ -86,6 +86,18 @@ export type AuthenticateRequestOptions = { * @default false */ satelliteAutoSync?: boolean; + /** + * When `true`, handshake payload resolution only runs for requests that are eligible for a + * handshake redirect (navigation requests). Non-GET requests and `fetch`/XHR calls ignore any + * handshake cookie or query param, so a stale handshake nonce no longer triggers a failing + * Backend API call on every request. Navigation requests still resolve and still redirect, so + * development instances keep working. Intended for API-only backends that cannot return + * `Set-Cookie` headers to the browser. + * + * @internal + * @default false + */ + __internal_resolveHandshakeOnlyForNavigation?: boolean; } & VerifyTokenOptions; /** diff --git a/packages/express/src/__tests__/clerkMiddleware.handshakeOptOut.test.ts b/packages/express/src/__tests__/clerkMiddleware.handshakeOptOut.test.ts new file mode 100644 index 00000000000..89a424b67ac --- /dev/null +++ b/packages/express/src/__tests__/clerkMiddleware.handshakeOptOut.test.ts @@ -0,0 +1,90 @@ +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { createClerkClient } from '@clerk/backend'; +import express from 'express'; +import supertest from 'supertest'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { clerkMiddleware } from '../clerkMiddleware'; + +// Nothing is mocked here: the real @clerk/backend runs behind the unchanged express middleware, +// which has no handshake-stripping logic of its own. A local HTTP server stands in for the +// Backend API so the test can observe whether the SDK performs the handshake payload exchange. +const PK_LIVE = 'pk_live_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA'; + +const payloadRequests: string[] = []; +let fakeBackendApi: Server; +let apiUrl = ''; + +beforeAll(async () => { + fakeBackendApi = createServer((request, response) => { + if (request.url?.startsWith('/v1/clients/handshake_payload')) { + payloadRequests.push(request.url); + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ errors: [{ code: 'resource_not_found', message: 'not found' }] })); + return; + } + response.writeHead(404).end(); + }); + await new Promise(resolve => fakeBackendApi.listen(0, '127.0.0.1', resolve)); + apiUrl = `http://127.0.0.1:${(fakeBackendApi.address() as AddressInfo).port}`; +}); + +afterAll(async () => { + await new Promise(resolve => fakeBackendApi.close(() => resolve())); +}); + +afterEach(() => { + payloadRequests.length = 0; + vi.restoreAllMocks(); +}); + +const buildApp = (middlewareOptions: Record = {}) => { + const clerkClient = createClerkClient({ secretKey: 'sk_live_deadbeef', publishableKey: PK_LIVE, apiUrl }); + const app = express(); + app.use( + clerkMiddleware({ clerkClient, secretKey: 'sk_live_deadbeef', publishableKey: PK_LIVE, ...middlewareOptions }), + ); + app.get('/api/me', (_request, response) => { + response.json({ + status: response.getHeader('x-clerk-auth-status'), + reason: response.getHeader('x-clerk-auth-reason'), + }); + }); + return app; +}; + +const staleNonceFetch = (request: supertest.Test) => + request + .set('cookie', '__clerk_handshake_nonce=stale; __client_uat=12345') + .set('sec-fetch-dest', 'empty') + .set('accept', '*/*'); + +describe('clerkMiddleware with __internal_resolveHandshakeOnlyForNavigation (real @clerk/backend)', () => { + it('by default a stale nonce on a fetch request triggers a failing Backend API call', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const response = await staleNonceFetch(supertest(buildApp()).get('/api/me')); + + expect(response.status).toBe(200); + expect(payloadRequests).toHaveLength(1); + expect(payloadRequests[0]).toContain('nonce=stale'); + expect(errorSpy).toHaveBeenCalled(); + // The failed exchange resolves to signed-out with the reason the customer observed in production. + expect(response.body).toEqual({ status: 'signed-out', reason: 'session-token-missing' }); + }); + + it('with __internal_resolveHandshakeOnlyForNavigation: true the unchanged middleware forwards the option and no Backend API call is made', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const response = await staleNonceFetch( + supertest(buildApp({ __internal_resolveHandshakeOnlyForNavigation: true })).get('/api/me'), + ); + + expect(response.status).toBe(200); + expect(payloadRequests).toHaveLength(0); + expect(errorSpy).not.toHaveBeenCalled(); + expect(response.body).toEqual({ status: 'signed-out', reason: 'client-uat-but-no-session-token' }); + }); +}); From f5e75e5b75bc137fa6e1429636cdc946fd583864 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 1 Sep 2026 20:48:08 -0700 Subject: [PATCH 2/9] refactor(backend): Simplify handshake gate and drop express proof test Claude-Session: https://claude.ai/code/session_014TUNghY1SiahtRY5t1zZWC --- .../backend-internal-enable-handshake.md | 2 +- .../express-handshake-opt-out-coverage.md | 2 - .../__tests__/request.handshakeOptOut.test.ts | 7 +- packages/backend/src/tokens/request.ts | 15 ++-- .../clerkMiddleware.handshakeOptOut.test.ts | 90 ------------------- 5 files changed, 12 insertions(+), 104 deletions(-) delete mode 100644 .changeset/express-handshake-opt-out-coverage.md delete mode 100644 packages/express/src/__tests__/clerkMiddleware.handshakeOptOut.test.ts diff --git a/.changeset/backend-internal-enable-handshake.md b/.changeset/backend-internal-enable-handshake.md index 6a3f403ae7c..b52c8b0bd3d 100644 --- a/.changeset/backend-internal-enable-handshake.md +++ b/.changeset/backend-internal-enable-handshake.md @@ -2,4 +2,4 @@ '@clerk/backend': patch --- -Add an internal `__internal_resolveHandshakeOnlyForNavigation` option to `authenticateRequest()` (defaults to `false`). When set to `true`, handshake cookies and query params are ignored on requests that cannot complete a handshake redirect (non-GET requests and `fetch`/XHR calls), so a stale handshake nonce no longer triggers a failing Backend API call on every request. Navigation requests are unaffected. Intended for API-only backends that cannot return `Set-Cookie` headers to the browser. +Add an internal `__internal_resolveHandshakeOnlyForNavigation` option to `authenticateRequest()`, off by default. When it's on, requests that can't complete a handshake redirect (anything that isn't a GET, plus `fetch` and XHR calls) ignore handshake cookies and query params instead of exchanging them with the Backend API. Page navigations behave exactly as before. Turn it on for an API-only backend sitting behind a proxy that drops `Set-Cookie`, where a stale handshake nonce would otherwise cost a failing Backend API call on every request. diff --git a/.changeset/express-handshake-opt-out-coverage.md b/.changeset/express-handshake-opt-out-coverage.md deleted file mode 100644 index a845151cc84..00000000000 --- a/.changeset/express-handshake-opt-out-coverage.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/packages/backend/src/tokens/__tests__/request.handshakeOptOut.test.ts b/packages/backend/src/tokens/__tests__/request.handshakeOptOut.test.ts index 8e120d2c3b3..70a3d11c5bd 100644 --- a/packages/backend/src/tokens/__tests__/request.handshakeOptOut.test.ts +++ b/packages/backend/src/tokens/__tests__/request.handshakeOptOut.test.ts @@ -1,6 +1,7 @@ import { http, HttpResponse } from 'msw'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import type { ApiClient } from '../../api'; import { mockJwks, mockJwt, mockJwtPayload } from '../../fixtures'; import { server } from '../../mock-server'; import { AuthErrorReason, AuthStatus } from '../authStatus'; @@ -32,7 +33,7 @@ const requestWith = ( const buildOptions = (overrides: Partial = {}) => { const getHandshakePayload = vi.fn().mockResolvedValue({ directives: [`__session=${mockJwt}; Path=/`] }); - const options = { + const options: AuthenticateRequestOptions = { secretKey: 'live_deadbeef', apiUrl: 'https://api.clerk.test', apiVersion: 'v1', @@ -45,9 +46,9 @@ const buildOptions = (overrides: Partial = {}) => { afterSignInUrl: '', afterSignUpUrl: '', domain: '', - apiClient: { clients: { getHandshakePayload } }, + apiClient: { clients: { getHandshakePayload } } as unknown as ApiClient, ...overrides, - } as unknown as AuthenticateRequestOptions; + }; return { options, getHandshakePayload }; }; diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index f4eef5f0222..a016be91cbd 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -461,15 +461,14 @@ export const authenticateRequest: AuthenticateRequest = (async ( /** * If we have a handshakeToken, resolve the handshake and attempt to return a definitive signed in or signed out state. + * With `__internal_resolveHandshakeOnlyForNavigation`, requests that cannot complete a handshake redirect skip this, + * so a stale handshake cookie on a fetch/XHR request does not trigger a failing payload exchange. */ - const hasHandshakeArtifacts = !!(authenticateContext.handshakeNonce || authenticateContext.handshakeToken); - // When resolution is limited to navigation requests, a stale handshake cookie on a fetch/XHR request must not trigger a - // (failing) payload exchange. Navigation requests still resolve so the redirect flow can complete. - const shouldResolveHandshake = - hasHandshakeArtifacts && - (authenticateContext.__internal_resolveHandshakeOnlyForNavigation !== true || - handshakeService.isRequestEligibleForHandshake()); - if (shouldResolveHandshake) { + const hasHandshakeToken = authenticateContext.handshakeNonce || authenticateContext.handshakeToken; + const canResolveHandshake = + !authenticateContext.__internal_resolveHandshakeOnlyForNavigation || + handshakeService.isRequestEligibleForHandshake(); + if (hasHandshakeToken && canResolveHandshake) { try { return await handshakeService.resolveHandshake(); } catch (error) { diff --git a/packages/express/src/__tests__/clerkMiddleware.handshakeOptOut.test.ts b/packages/express/src/__tests__/clerkMiddleware.handshakeOptOut.test.ts deleted file mode 100644 index 89a424b67ac..00000000000 --- a/packages/express/src/__tests__/clerkMiddleware.handshakeOptOut.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { createServer, type Server } from 'node:http'; -import type { AddressInfo } from 'node:net'; - -import { createClerkClient } from '@clerk/backend'; -import express from 'express'; -import supertest from 'supertest'; -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; - -import { clerkMiddleware } from '../clerkMiddleware'; - -// Nothing is mocked here: the real @clerk/backend runs behind the unchanged express middleware, -// which has no handshake-stripping logic of its own. A local HTTP server stands in for the -// Backend API so the test can observe whether the SDK performs the handshake payload exchange. -const PK_LIVE = 'pk_live_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA'; - -const payloadRequests: string[] = []; -let fakeBackendApi: Server; -let apiUrl = ''; - -beforeAll(async () => { - fakeBackendApi = createServer((request, response) => { - if (request.url?.startsWith('/v1/clients/handshake_payload')) { - payloadRequests.push(request.url); - response.writeHead(404, { 'content-type': 'application/json' }); - response.end(JSON.stringify({ errors: [{ code: 'resource_not_found', message: 'not found' }] })); - return; - } - response.writeHead(404).end(); - }); - await new Promise(resolve => fakeBackendApi.listen(0, '127.0.0.1', resolve)); - apiUrl = `http://127.0.0.1:${(fakeBackendApi.address() as AddressInfo).port}`; -}); - -afterAll(async () => { - await new Promise(resolve => fakeBackendApi.close(() => resolve())); -}); - -afterEach(() => { - payloadRequests.length = 0; - vi.restoreAllMocks(); -}); - -const buildApp = (middlewareOptions: Record = {}) => { - const clerkClient = createClerkClient({ secretKey: 'sk_live_deadbeef', publishableKey: PK_LIVE, apiUrl }); - const app = express(); - app.use( - clerkMiddleware({ clerkClient, secretKey: 'sk_live_deadbeef', publishableKey: PK_LIVE, ...middlewareOptions }), - ); - app.get('/api/me', (_request, response) => { - response.json({ - status: response.getHeader('x-clerk-auth-status'), - reason: response.getHeader('x-clerk-auth-reason'), - }); - }); - return app; -}; - -const staleNonceFetch = (request: supertest.Test) => - request - .set('cookie', '__clerk_handshake_nonce=stale; __client_uat=12345') - .set('sec-fetch-dest', 'empty') - .set('accept', '*/*'); - -describe('clerkMiddleware with __internal_resolveHandshakeOnlyForNavigation (real @clerk/backend)', () => { - it('by default a stale nonce on a fetch request triggers a failing Backend API call', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - const response = await staleNonceFetch(supertest(buildApp()).get('/api/me')); - - expect(response.status).toBe(200); - expect(payloadRequests).toHaveLength(1); - expect(payloadRequests[0]).toContain('nonce=stale'); - expect(errorSpy).toHaveBeenCalled(); - // The failed exchange resolves to signed-out with the reason the customer observed in production. - expect(response.body).toEqual({ status: 'signed-out', reason: 'session-token-missing' }); - }); - - it('with __internal_resolveHandshakeOnlyForNavigation: true the unchanged middleware forwards the option and no Backend API call is made', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - const response = await staleNonceFetch( - supertest(buildApp({ __internal_resolveHandshakeOnlyForNavigation: true })).get('/api/me'), - ); - - expect(response.status).toBe(200); - expect(payloadRequests).toHaveLength(0); - expect(errorSpy).not.toHaveBeenCalled(); - expect(response.body).toEqual({ status: 'signed-out', reason: 'client-uat-but-no-session-token' }); - }); -}); From 9dc19dbf7fb5a05f2ef0c40d5671b50b6d6c7693 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 1 Sep 2026 20:54:14 -0700 Subject: [PATCH 3/9] test(backend): Fold handshake opt-out tests into request.test.ts Claude-Session: https://claude.ai/code/session_014TUNghY1SiahtRY5t1zZWC --- .../__tests__/request.handshakeOptOut.test.ts | 220 ------------------ .../src/tokens/__tests__/request.test.ts | 189 +++++++++++++++ 2 files changed, 189 insertions(+), 220 deletions(-) delete mode 100644 packages/backend/src/tokens/__tests__/request.handshakeOptOut.test.ts diff --git a/packages/backend/src/tokens/__tests__/request.handshakeOptOut.test.ts b/packages/backend/src/tokens/__tests__/request.handshakeOptOut.test.ts deleted file mode 100644 index 70a3d11c5bd..00000000000 --- a/packages/backend/src/tokens/__tests__/request.handshakeOptOut.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { http, HttpResponse } from 'msw'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; - -import type { ApiClient } from '../../api'; -import { mockJwks, mockJwt, mockJwtPayload } from '../../fixtures'; -import { server } from '../../mock-server'; -import { AuthErrorReason, AuthStatus } from '../authStatus'; -import { HandshakeService } from '../handshake'; -import { authenticateRequest } from '../request'; -import type { AuthenticateRequestOptions } from '../types'; - -const PK_TEST = 'pk_test_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA'; -const PK_LIVE = 'pk_live_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA'; - -const navigationHeaders = { host: 'example.com', 'user-agent': 'Mozilla/TestAgent', 'sec-fetch-dest': 'document' }; -const fetchHeaders = { - host: 'example.com', - 'user-agent': 'Mozilla/TestAgent', - 'sec-fetch-dest': 'empty', - accept: '*/*', -}; - -const requestWith = ( - headers: Record, - cookies: Record = {}, - { method = 'GET', url = 'http://example.com/api/me' } = {}, -) => { - const cookie = Object.entries(cookies) - .map(([k, v]) => `${k}=${v}`) - .join(';'); - return new Request(url, { method, headers: { ...headers, cookie } }); -}; - -const buildOptions = (overrides: Partial = {}) => { - const getHandshakePayload = vi.fn().mockResolvedValue({ directives: [`__session=${mockJwt}; Path=/`] }); - const options: AuthenticateRequestOptions = { - secretKey: 'live_deadbeef', - apiUrl: 'https://api.clerk.test', - apiVersion: 'v1', - publishableKey: PK_LIVE, - proxyUrl: '', - skipJwksCache: true, - isSatellite: false, - signInUrl: '', - signUpUrl: '', - afterSignInUrl: '', - afterSignUpUrl: '', - domain: '', - apiClient: { clients: { getHandshakePayload } } as unknown as ApiClient, - ...overrides, - }; - return { options, getHandshakePayload }; -}; - -describe('authenticateRequest with __internal_resolveHandshakeOnlyForNavigation: true', () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date(mockJwtPayload.iat * 1000)); - server.use(http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks))); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - test('fetch request with a stale nonce cookie skips the payload exchange and authenticates from the session cookie', async () => { - const { options, getHandshakePayload } = buildOptions({ __internal_resolveHandshakeOnlyForNavigation: true }); - - const state = await authenticateRequest( - requestWith(fetchHeaders, { __clerk_handshake_nonce: 'stale', __client_uat: '12345', __session: mockJwt }), - options, - ); - - expect(getHandshakePayload).not.toHaveBeenCalled(); - expect(state.status).toBe(AuthStatus.SignedIn); - expect(state.headers.get('location')).toBeNull(); - }); - - test('POST request with a stale nonce cookie skips the payload exchange', async () => { - const { options, getHandshakePayload } = buildOptions({ __internal_resolveHandshakeOnlyForNavigation: true }); - - const state = await authenticateRequest( - requestWith( - navigationHeaders, - { __clerk_handshake_nonce: 'stale', __client_uat: '12345', __session: mockJwt }, - { method: 'POST' }, - ), - options, - ); - - expect(getHandshakePayload).not.toHaveBeenCalled(); - expect(state.status).toBe(AuthStatus.SignedIn); - }); - - test('fetch request with a stale nonce cookie and no session is signed out without a payload exchange', async () => { - const { options, getHandshakePayload } = buildOptions({ __internal_resolveHandshakeOnlyForNavigation: true }); - - const state = await authenticateRequest( - requestWith(fetchHeaders, { __clerk_handshake_nonce: 'stale', __client_uat: '12345' }), - options, - ); - - expect(getHandshakePayload).not.toHaveBeenCalled(); - expect(state.status).toBe(AuthStatus.SignedOut); - expect(state.reason).toBe(AuthErrorReason.ClientUATWithoutSessionToken); - expect(state.headers.get('location')).toBeNull(); - }); - - test('navigation request with a nonce cookie still exchanges the payload', async () => { - const { options, getHandshakePayload } = buildOptions({ __internal_resolveHandshakeOnlyForNavigation: true }); - - const state = await authenticateRequest( - requestWith(navigationHeaders, { __clerk_handshake_nonce: 'fresh', __client_uat: '12345' }), - options, - ); - - expect(getHandshakePayload).toHaveBeenCalledTimes(1); - expect(getHandshakePayload).toHaveBeenCalledWith({ nonce: 'fresh' }); - expect(state.status).toBe(AuthStatus.SignedIn); - }); - - test('by default a fetch request with a nonce cookie still exchanges the payload', async () => { - const { options, getHandshakePayload } = buildOptions(); - - const state = await authenticateRequest( - requestWith(fetchHeaders, { __clerk_handshake_nonce: 'fresh', __client_uat: '12345' }), - options, - ); - - expect(getHandshakePayload).toHaveBeenCalledTimes(1); - expect(state.status).toBe(AuthStatus.SignedIn); - }); - - test('development instance still redirects a navigation request to the dev browser handshake', async () => { - const { options, getHandshakePayload } = buildOptions({ - __internal_resolveHandshakeOnlyForNavigation: true, - publishableKey: PK_TEST, - secretKey: 'test_deadbeef', - }); - - const state = await authenticateRequest(requestWith(navigationHeaders), options); - - expect(getHandshakePayload).not.toHaveBeenCalled(); - expect(state.status).toBe(AuthStatus.Handshake); - expect(state.reason).toBe(AuthErrorReason.DevBrowserMissing); - expect(state.headers.get('location')).toContain('/v1/client/handshake'); - }); - - test('development navigation request returning from the handshake still resolves the nonce', async () => { - const { options, getHandshakePayload } = buildOptions({ - __internal_resolveHandshakeOnlyForNavigation: true, - publishableKey: PK_TEST, - secretKey: 'test_deadbeef', - }); - - const state = await authenticateRequest( - requestWith(navigationHeaders, { __clerk_handshake_nonce: 'fresh' }), - options, - ); - - expect(getHandshakePayload).toHaveBeenCalledTimes(1); - expect(state.status).toBe(AuthStatus.SignedIn); - }); - - test('stale cookie-transport handshake token on a fetch request is ignored without logging', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const { options } = buildOptions({ __internal_resolveHandshakeOnlyForNavigation: true }); - - const state = await authenticateRequest( - requestWith(fetchHeaders, { __clerk_handshake: 'not-a-jwt', __client_uat: '12345', __session: mockJwt }), - options, - ); - - expect(errorSpy).not.toHaveBeenCalled(); - expect(state.status).toBe(AuthStatus.SignedIn); - }); - - test.each([ - ['omitted', {}], - ['explicitly false', { __internal_resolveHandshakeOnlyForNavigation: false }], - ])('when the option is %s the eligibility check is never consulted and the payload is exchanged', async (_, flag) => { - const eligibilitySpy = vi.spyOn(HandshakeService.prototype, 'isRequestEligibleForHandshake'); - const { options, getHandshakePayload } = buildOptions(flag); - - await authenticateRequest( - requestWith(fetchHeaders, { __clerk_handshake_nonce: 'fresh', __client_uat: '12345' }), - options, - ); - - expect(eligibilitySpy).not.toHaveBeenCalled(); - expect(getHandshakePayload).toHaveBeenCalledTimes(1); - }); - - test('only an explicit true consults the eligibility check', async () => { - const eligibilitySpy = vi.spyOn(HandshakeService.prototype, 'isRequestEligibleForHandshake'); - const { options, getHandshakePayload } = buildOptions({ __internal_resolveHandshakeOnlyForNavigation: true }); - - await authenticateRequest( - requestWith(fetchHeaders, { __clerk_handshake_nonce: 'fresh', __client_uat: '12345' }), - options, - ); - - expect(eligibilitySpy).toHaveBeenCalled(); - expect(getHandshakePayload).not.toHaveBeenCalled(); - }); - - test('by default a stale cookie-transport handshake token on a fetch request logs a resolution error', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const { options } = buildOptions(); - - const state = await authenticateRequest( - requestWith(fetchHeaders, { __clerk_handshake: 'not-a-jwt', __client_uat: '12345', __session: mockJwt }), - options, - ); - - expect(errorSpy).toHaveBeenCalledWith('Clerk: unable to resolve handshake:', expect.anything()); - expect(state.status).toBe(AuthStatus.SignedIn); - }); -}); diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index 947507a2d99..a9bb9da53fc 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -1,6 +1,7 @@ import { http, HttpResponse } from 'msw'; import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest'; +import type { ApiClient } from '../../api'; import { MachineTokenVerificationErrorCode, TokenVerificationErrorReason } from '../../errors'; import { mockExpiredJwt, @@ -21,6 +22,7 @@ import { signJwt } from '../../jwt/signJwt'; import { server } from '../../mock-server'; import type { AuthReason } from '../authStatus'; import { AuthErrorReason, AuthStatus } from '../authStatus'; +import { HandshakeService } from '../handshake'; import { JWT_CATEGORY_JWT_TEMPLATE } from '../jwtCategories'; import { OrganizationMatcher } from '../organizationMatcher'; import { authenticateRequest, RefreshTokenErrorReason } from '../request'; @@ -2467,4 +2469,191 @@ describe('tokens.authenticateRequest(options)', () => { expect(requestState).toBeSignedOut({ reason: AuthErrorReason.SessionTokenIATBeforeClientUAT }); }); }); + + describe('__internal_resolveHandshakeOnlyForNavigation', () => { + const fetchHeaders = { 'sec-fetch-dest': 'empty', accept: '*/*' }; + + const mockOptionsWithHandshakePayload = (overrides: Partial = {}) => { + const getHandshakePayload = vi.fn().mockResolvedValue({ directives: [`__session=${mockJwt}; Path=/`] }); + const options = mockOptions({ + publishableKey: PK_LIVE, + apiClient: { clients: { getHandshakePayload } } as unknown as ApiClient, + ...overrides, + }); + return { options, getHandshakePayload }; + }; + + beforeEach(() => { + server.use( + http.get('https://api.clerk.test/v1/jwks', () => { + return HttpResponse.json(mockJwks); + }), + ); + }); + + test('skips the payload exchange on a fetch request with a stale nonce and authenticates from the session cookie', async () => { + const { options, getHandshakePayload } = mockOptionsWithHandshakePayload({ + __internal_resolveHandshakeOnlyForNavigation: true, + }); + + const requestState = await authenticateRequest( + mockRequestWithCookies(fetchHeaders, { + __clerk_handshake_nonce: 'stale', + __client_uat: '12345', + __session: mockJwt, + }), + options, + ); + + expect(getHandshakePayload).not.toHaveBeenCalled(); + expect(requestState).toBeSignedIn(); + expect(requestState.headers.get('location')).toBeNull(); + }); + + test('skips the payload exchange on a POST request with a stale nonce', async () => { + const { options, getHandshakePayload } = mockOptionsWithHandshakePayload({ + __internal_resolveHandshakeOnlyForNavigation: true, + }); + + const requestState = await authenticateRequest( + new Request('http://clerk.com/path', { + method: 'POST', + headers: { + ...defaultHeaders, + cookie: `__clerk_handshake_nonce=stale;__client_uat=12345;__session=${mockJwt}`, + }, + }), + options, + ); + + expect(getHandshakePayload).not.toHaveBeenCalled(); + expect(requestState).toBeSignedIn(); + }); + + test('returns signed out without a payload exchange on a fetch request with a stale nonce and no session', async () => { + const { options, getHandshakePayload } = mockOptionsWithHandshakePayload({ + __internal_resolveHandshakeOnlyForNavigation: true, + }); + + const requestState = await authenticateRequest( + mockRequestWithCookies(fetchHeaders, { __clerk_handshake_nonce: 'stale', __client_uat: '12345' }), + options, + ); + + expect(getHandshakePayload).not.toHaveBeenCalled(); + expect(requestState).toBeSignedOut({ reason: AuthErrorReason.ClientUATWithoutSessionToken }); + expect(requestState.headers.get('location')).toBeNull(); + }); + + test('still exchanges the payload on a navigation request with a nonce', async () => { + const { options, getHandshakePayload } = mockOptionsWithHandshakePayload({ + __internal_resolveHandshakeOnlyForNavigation: true, + }); + + const requestState = await authenticateRequest( + mockRequestWithCookies({}, { __clerk_handshake_nonce: 'fresh', __client_uat: '12345' }), + options, + ); + + expect(getHandshakePayload).toHaveBeenCalledWith({ nonce: 'fresh' }); + expect(requestState).toBeSignedIn(); + }); + + test('still redirects a development navigation request to the dev browser handshake', async () => { + const { options, getHandshakePayload } = mockOptionsWithHandshakePayload({ + __internal_resolveHandshakeOnlyForNavigation: true, + publishableKey: PK_TEST, + secretKey: 'test_deadbeef', + }); + + const requestState = await authenticateRequest(mockRequestWithCookies(), options); + + expect(getHandshakePayload).not.toHaveBeenCalled(); + expect(requestState).toMatchHandshake({ reason: AuthErrorReason.DevBrowserMissing }); + }); + + test('still resolves the nonce on a development navigation request returning from the handshake', async () => { + const { options, getHandshakePayload } = mockOptionsWithHandshakePayload({ + __internal_resolveHandshakeOnlyForNavigation: true, + publishableKey: PK_TEST, + secretKey: 'test_deadbeef', + }); + + const requestState = await authenticateRequest( + mockRequestWithCookies({}, { __clerk_handshake_nonce: 'fresh' }), + options, + ); + + expect(getHandshakePayload).toHaveBeenCalledTimes(1); + expect(requestState).toBeSignedIn(); + }); + + test('ignores a stale cookie-transport handshake token on a fetch request without logging', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { options } = mockOptionsWithHandshakePayload({ __internal_resolveHandshakeOnlyForNavigation: true }); + + const requestState = await authenticateRequest( + mockRequestWithCookies(fetchHeaders, { + __clerk_handshake: 'not-a-jwt', + __client_uat: '12345', + __session: mockJwt, + }), + options, + ); + + expect(errorSpy).not.toHaveBeenCalled(); + expect(requestState).toBeSignedIn(); + }); + + test('by default logs a resolution error for a stale cookie-transport handshake token on a fetch request', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { options } = mockOptionsWithHandshakePayload(); + + const requestState = await authenticateRequest( + mockRequestWithCookies(fetchHeaders, { + __clerk_handshake: 'not-a-jwt', + __client_uat: '12345', + __session: mockJwt, + }), + options, + ); + + expect(errorSpy).toHaveBeenCalledWith('Clerk: unable to resolve handshake:', expect.anything()); + expect(requestState).toBeSignedIn(); + }); + + test.each([ + ['omitted', {}], + ['false', { __internal_resolveHandshakeOnlyForNavigation: false }], + ])( + 'when the option is %s the eligibility check is never consulted and the payload is exchanged', + async (_, flag) => { + const eligibilitySpy = vi.spyOn(HandshakeService.prototype, 'isRequestEligibleForHandshake'); + const { options, getHandshakePayload } = mockOptionsWithHandshakePayload(flag); + + await authenticateRequest( + mockRequestWithCookies(fetchHeaders, { __clerk_handshake_nonce: 'fresh', __client_uat: '12345' }), + options, + ); + + expect(eligibilitySpy).not.toHaveBeenCalled(); + expect(getHandshakePayload).toHaveBeenCalledTimes(1); + }, + ); + + test('only an explicit true consults the eligibility check', async () => { + const eligibilitySpy = vi.spyOn(HandshakeService.prototype, 'isRequestEligibleForHandshake'); + const { options, getHandshakePayload } = mockOptionsWithHandshakePayload({ + __internal_resolveHandshakeOnlyForNavigation: true, + }); + + await authenticateRequest( + mockRequestWithCookies(fetchHeaders, { __clerk_handshake_nonce: 'fresh', __client_uat: '12345' }), + options, + ); + + expect(eligibilitySpy).toHaveBeenCalled(); + expect(getHandshakePayload).not.toHaveBeenCalled(); + }); + }); }); From 7ac792ee4fd5fc37003c665e384a613b772b11ab Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 1 Sep 2026 21:37:18 -0700 Subject: [PATCH 4/9] refactor(fastify): Delegate handshake opt-out to @clerk/backend With __internal_enableHandshake: false the plugin now forwards __internal_resolveHandshakeOnlyForNavigation to authenticateRequest instead of stripping handshake cookies and query params itself. The redirect handling (dev-browser exception, signed-out fallback) is unchanged. Claude-Session: https://claude.ai/code/session_014TUNghY1SiahtRY5t1zZWC --- .../fastify-delegate-handshake-opt-out.md | 5 +++ .../src/__tests__/withClerkMiddleware.test.ts | 34 ++++++------------- packages/fastify/src/types.ts | 6 ++-- packages/fastify/src/utils.ts | 30 ---------------- packages/fastify/src/withClerkMiddleware.ts | 7 ++-- 5 files changed, 21 insertions(+), 61 deletions(-) create mode 100644 .changeset/fastify-delegate-handshake-opt-out.md diff --git a/.changeset/fastify-delegate-handshake-opt-out.md b/.changeset/fastify-delegate-handshake-opt-out.md new file mode 100644 index 00000000000..ace38d18fca --- /dev/null +++ b/.changeset/fastify-delegate-handshake-opt-out.md @@ -0,0 +1,5 @@ +--- +'@clerk/fastify': patch +--- + +`clerkPlugin()` with `__internal_enableHandshake: false` now relies on `@clerk/backend` to ignore stale handshake cookies on API requests instead of stripping them itself. Behavior is unchanged for API-only backends. Handshake redirects are still skipped, and dev-browser handshakes still go through. diff --git a/packages/fastify/src/__tests__/withClerkMiddleware.test.ts b/packages/fastify/src/__tests__/withClerkMiddleware.test.ts index 0eb86d41560..191e75d7254 100644 --- a/packages/fastify/src/__tests__/withClerkMiddleware.test.ts +++ b/packages/fastify/src/__tests__/withClerkMiddleware.test.ts @@ -408,7 +408,7 @@ describe('withClerkMiddleware(options)', () => { }, ); - test('strips handshake cookies and query params before authenticating when __internal_enableHandshake is false', async () => { + test('asks @clerk/backend to resolve handshakes only for navigation when __internal_enableHandshake is false', async () => { authenticateRequestMock.mockResolvedValueOnce({ headers: new Headers(), toAuth: () => ({ tokenType: 'session_token' }), @@ -422,22 +422,17 @@ describe('withClerkMiddleware(options)', () => { await fastify.inject({ method: 'GET', - path: '/?__clerk_handshake=token123&__clerk_handshake_nonce=nonce456&foo=bar', - headers: { - cookie: '__clerk_handshake=token123; __clerk_handshake_nonce=nonce456; __client_uat=1675692233', - }, + path: '/?__clerk_handshake_nonce=nonce456', + headers: { cookie: '__clerk_handshake_nonce=nonce456; __client_uat=1675692233' }, }); - const [req] = authenticateRequestMock.mock.calls[0]; - expect(new URL(req.url).searchParams.has('__clerk_handshake')).toBe(false); - expect(new URL(req.url).searchParams.has('__clerk_handshake_nonce')).toBe(false); - expect(new URL(req.url).searchParams.get('foo')).toBe('bar'); - expect(req.headers.get('cookie')).not.toContain('__clerk_handshake='); - expect(req.headers.get('cookie')).not.toContain('__clerk_handshake_nonce='); - expect(req.headers.get('cookie')).toContain('__client_uat=1675692233'); + const [req, options] = authenticateRequestMock.mock.calls[0]; + expect(options).toEqual(expect.objectContaining({ __internal_resolveHandshakeOnlyForNavigation: true })); + expect(options).not.toHaveProperty('__internal_enableHandshake'); + expect(req.headers.get('cookie')).toContain('__clerk_handshake_nonce=nonce456'); }); - test('does not strip handshake cookies or query params by default', async () => { + test('leaves handshake resolution enabled by default', async () => { authenticateRequestMock.mockResolvedValueOnce({ headers: new Headers(), toAuth: () => ({ tokenType: 'session_token' }), @@ -449,16 +444,9 @@ describe('withClerkMiddleware(options)', () => { reply.send({}); }); - await fastify.inject({ - method: 'GET', - path: '/?__clerk_handshake=token123', - headers: { - cookie: '__clerk_handshake_nonce=nonce456; __client_uat=1675692233', - }, - }); + await fastify.inject({ method: 'GET', path: '/' }); - const [req] = authenticateRequestMock.mock.calls[0]; - expect(new URL(req.url).searchParams.get('__clerk_handshake')).toBe('token123'); - expect(req.headers.get('cookie')).toContain('__clerk_handshake_nonce=nonce456'); + const [, options] = authenticateRequestMock.mock.calls[0]; + expect(options).toEqual(expect.objectContaining({ __internal_resolveHandshakeOnlyForNavigation: false })); }); }); diff --git a/packages/fastify/src/types.ts b/packages/fastify/src/types.ts index 2b02c7eaf48..965ef3b32a8 100644 --- a/packages/fastify/src/types.ts +++ b/packages/fastify/src/types.ts @@ -30,9 +30,9 @@ export type ClerkFastifyOptions = ClerkOptions & { /** * Whether to enable the handshake flow for session verification. * - * When set to `false`, the plugin strips handshake cookies (`__clerk_handshake`, - * `__clerk_handshake_nonce`) and query params before authenticating the request, and - * skips handshake redirects (except dev-browser handshakes, which development + * When set to `false`, handshake cookies and query params are ignored on requests that + * cannot complete a handshake redirect (non-GET requests and `fetch`/XHR calls), and + * handshake redirects are skipped (except dev-browser handshakes, which development * instances require). Intended for pure API backends (e.g. a SPA calling a Fastify * server) where the server cannot deliver `Set-Cookie` headers back to the browser, * so stale handshake nonces would otherwise be replayed and trigger repeated `404` diff --git a/packages/fastify/src/utils.ts b/packages/fastify/src/utils.ts index 3754add80b2..1b36da0ef9b 100644 --- a/packages/fastify/src/utils.ts +++ b/packages/fastify/src/utils.ts @@ -61,33 +61,3 @@ export const requestToProxyRequest = (req: FastifyRequest): Request => { duplex: hasBody ? 'half' : undefined, }); }; - -/** - * Removes handshake artifacts from a request before authentication. Handshake cookies and - * query params share the same names (`QueryParameters` aliases `Cookies` in `@clerk/backend`), - * so one list covers both. - */ -export const stripHandshakeCookiesAndParams = (req: Request, names: string[]): Request => { - const url = new URL(req.url); - for (const name of names) { - url.searchParams.delete(name); - } - - const headers = new Headers(req.headers); - const cookieHeader = headers.get('cookie'); - if (cookieHeader) { - const filtered = cookieHeader - .split(';') - .map(c => c.trim()) - .filter(c => !names.some(name => c === name || c.startsWith(`${name}=`))) - .join('; '); - if (filtered) { - headers.set('cookie', filtered); - } else { - headers.delete('cookie'); - } - } - - // The body is dropped; this request is only passed to `authenticateRequest`, which never reads it. - return new Request(url.toString(), { method: req.method, headers }); -}; diff --git a/packages/fastify/src/withClerkMiddleware.ts b/packages/fastify/src/withClerkMiddleware.ts index 20b177e76a9..765bd73f771 100644 --- a/packages/fastify/src/withClerkMiddleware.ts +++ b/packages/fastify/src/withClerkMiddleware.ts @@ -7,7 +7,7 @@ import { Readable } from 'stream'; import * as constants from './constants'; import type { ClerkFastifyOptions } from './types'; -import { fastifyRequestToRequest, requestToProxyRequest, stripHandshakeCookiesAndParams } from './utils'; +import { fastifyRequestToRequest, requestToProxyRequest } from './utils'; export const withClerkMiddleware = (options: ClerkFastifyOptions) => { const { hookName: _hookName, frontendApiProxy, __internal_enableHandshake, ...clerkOptions } = options; @@ -103,16 +103,13 @@ export const withClerkMiddleware = (options: ClerkFastifyOptions) => { return reply.code(400).send(); } - if (!enableHandshake) { - req = stripHandshakeCookiesAndParams(req, [constants.Cookies.Handshake, constants.Cookies.HandshakeNonce]); - } - const requestState = await clerkClient.authenticateRequest(req, { ...clerkOptions, secretKey, publishableKey, proxyUrl: resolvedProxyUrl, acceptsToken: 'any', + __internal_resolveHandshakeOnlyForNavigation: !enableHandshake, }); requestState.headers.forEach((value, key) => reply.header(key, value)); From a94833ea6d104fa0665b578b38dbba99fcf0cd5e Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 1 Sep 2026 21:39:36 -0700 Subject: [PATCH 5/9] docs(backend): Describe handshake opt-out by eligibility rule Claude-Session: https://claude.ai/code/session_014TUNghY1SiahtRY5t1zZWC --- .changeset/backend-internal-enable-handshake.md | 2 +- packages/backend/src/tokens/request.ts | 4 ++-- packages/backend/src/tokens/types.ts | 9 ++++----- packages/fastify/src/types.ts | 5 ++--- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/.changeset/backend-internal-enable-handshake.md b/.changeset/backend-internal-enable-handshake.md index b52c8b0bd3d..c06095a73d7 100644 --- a/.changeset/backend-internal-enable-handshake.md +++ b/.changeset/backend-internal-enable-handshake.md @@ -2,4 +2,4 @@ '@clerk/backend': patch --- -Add an internal `__internal_resolveHandshakeOnlyForNavigation` option to `authenticateRequest()`, off by default. When it's on, requests that can't complete a handshake redirect (anything that isn't a GET, plus `fetch` and XHR calls) ignore handshake cookies and query params instead of exchanging them with the Backend API. Page navigations behave exactly as before. Turn it on for an API-only backend sitting behind a proxy that drops `Set-Cookie`, where a stale handshake nonce would otherwise cost a failing Backend API call on every request. +Add an internal `__internal_resolveHandshakeOnlyForNavigation` option to `authenticateRequest()`, off by default. When it's on, requests that are not eligible for a handshake redirect ignore handshake cookies and query params instead of exchanging them with the Backend API. Page navigations behave exactly as before. Turn it on for an API-only backend sitting behind a proxy that drops `Set-Cookie`, where a stale handshake nonce would otherwise cost a failing Backend API call on every request. diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index a016be91cbd..5a22e0bd200 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -461,8 +461,8 @@ export const authenticateRequest: AuthenticateRequest = (async ( /** * If we have a handshakeToken, resolve the handshake and attempt to return a definitive signed in or signed out state. - * With `__internal_resolveHandshakeOnlyForNavigation`, requests that cannot complete a handshake redirect skip this, - * so a stale handshake cookie on a fetch/XHR request does not trigger a failing payload exchange. + * With `__internal_resolveHandshakeOnlyForNavigation`, requests that are not eligible for a handshake redirect + * skip this, so a stale handshake cookie does not trigger a failing payload exchange. */ const hasHandshakeToken = authenticateContext.handshakeNonce || authenticateContext.handshakeToken; const canResolveHandshake = diff --git a/packages/backend/src/tokens/types.ts b/packages/backend/src/tokens/types.ts index 4168843fdf6..04ebcebccb2 100644 --- a/packages/backend/src/tokens/types.ts +++ b/packages/backend/src/tokens/types.ts @@ -88,11 +88,10 @@ export type AuthenticateRequestOptions = { satelliteAutoSync?: boolean; /** * When `true`, handshake payload resolution only runs for requests that are eligible for a - * handshake redirect (navigation requests). Non-GET requests and `fetch`/XHR calls ignore any - * handshake cookie or query param, so a stale handshake nonce no longer triggers a failing - * Backend API call on every request. Navigation requests still resolve and still redirect, so - * development instances keep working. Intended for API-only backends that cannot return - * `Set-Cookie` headers to the browser. + * handshake redirect. Requests that are not eligible ignore any handshake cookie or query param, + * so a stale handshake nonce no longer triggers a failing Backend API call on every request. + * Eligible requests still resolve and still redirect, so development instances keep working. + * Intended for API-only backends that cannot return `Set-Cookie` headers to the browser. * * @internal * @default false diff --git a/packages/fastify/src/types.ts b/packages/fastify/src/types.ts index 965ef3b32a8..125f3d5b246 100644 --- a/packages/fastify/src/types.ts +++ b/packages/fastify/src/types.ts @@ -31,9 +31,8 @@ export type ClerkFastifyOptions = ClerkOptions & { * Whether to enable the handshake flow for session verification. * * When set to `false`, handshake cookies and query params are ignored on requests that - * cannot complete a handshake redirect (non-GET requests and `fetch`/XHR calls), and - * handshake redirects are skipped (except dev-browser handshakes, which development - * instances require). Intended for pure API backends (e.g. a SPA calling a Fastify + * are not eligible for a handshake redirect, and handshake redirects are skipped (except + * dev-browser handshakes, which development instances require). Intended for pure API backends (e.g. a SPA calling a Fastify * server) where the server cannot deliver `Set-Cookie` headers back to the browser, * so stale handshake nonces would otherwise be replayed and trigger repeated `404` * errors from the Frontend API. From b2054bbd15cdb392fc18b17037c404ca7a9af53e Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 1 Sep 2026 21:44:30 -0700 Subject: [PATCH 6/9] docs(fastify): Rewrap __internal_enableHandshake JSDoc Claude-Session: https://claude.ai/code/session_014TUNghY1SiahtRY5t1zZWC --- packages/fastify/src/types.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/fastify/src/types.ts b/packages/fastify/src/types.ts index 125f3d5b246..45c4dca7ad4 100644 --- a/packages/fastify/src/types.ts +++ b/packages/fastify/src/types.ts @@ -32,10 +32,10 @@ export type ClerkFastifyOptions = ClerkOptions & { * * When set to `false`, handshake cookies and query params are ignored on requests that * are not eligible for a handshake redirect, and handshake redirects are skipped (except - * dev-browser handshakes, which development instances require). Intended for pure API backends (e.g. a SPA calling a Fastify - * server) where the server cannot deliver `Set-Cookie` headers back to the browser, - * so stale handshake nonces would otherwise be replayed and trigger repeated `404` - * errors from the Frontend API. + * dev-browser handshakes, which development instances require). Intended for pure API + * backends (e.g. a SPA calling a Fastify server) where the server cannot deliver + * `Set-Cookie` headers back to the browser, so stale handshake nonces would otherwise be + * replayed and trigger repeated `404` errors from the Frontend API. * * @internal * @default true From 028d609515f806369abeb478b05633b20a34db77 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 1 Sep 2026 21:58:09 -0700 Subject: [PATCH 7/9] refactor(fastify): Drop handshake redirect suppression With the backend option in place the plugin no longer needs to suppress handshake redirects, so the dev-browser exception, header cleanup, and signed-out fallback go away and the redirect path returns to its pre-#8560 shape. Adds an end-to-end test through the real backend for the opt-out, including both development sandbox flows. Claude-Session: https://claude.ai/code/session_014TUNghY1SiahtRY5t1zZWC --- .../fastify-delegate-handshake-opt-out.md | 2 +- .../withClerkMiddleware.handshake.test.ts | 127 ++++++++++++++++++ .../src/__tests__/withClerkMiddleware.test.ts | 93 ------------- packages/fastify/src/types.ts | 9 +- packages/fastify/src/withClerkMiddleware.ts | 17 +-- 5 files changed, 136 insertions(+), 112 deletions(-) create mode 100644 packages/fastify/src/__tests__/withClerkMiddleware.handshake.test.ts diff --git a/.changeset/fastify-delegate-handshake-opt-out.md b/.changeset/fastify-delegate-handshake-opt-out.md index ace38d18fca..dfe56bd7a32 100644 --- a/.changeset/fastify-delegate-handshake-opt-out.md +++ b/.changeset/fastify-delegate-handshake-opt-out.md @@ -2,4 +2,4 @@ '@clerk/fastify': patch --- -`clerkPlugin()` with `__internal_enableHandshake: false` now relies on `@clerk/backend` to ignore stale handshake cookies on API requests instead of stripping them itself. Behavior is unchanged for API-only backends. Handshake redirects are still skipped, and dev-browser handshakes still go through. +`clerkPlugin()` with `__internal_enableHandshake: false` now relies on `@clerk/backend` to ignore stale handshake cookies on requests that cannot complete a handshake, instead of stripping them itself. Handshake redirects are no longer suppressed for the rare navigation request that reaches an API-only backend; they behave the same as in every other Clerk SDK. diff --git a/packages/fastify/src/__tests__/withClerkMiddleware.handshake.test.ts b/packages/fastify/src/__tests__/withClerkMiddleware.handshake.test.ts new file mode 100644 index 00000000000..6a3ecab91cc --- /dev/null +++ b/packages/fastify/src/__tests__/withClerkMiddleware.handshake.test.ts @@ -0,0 +1,127 @@ +import type { AddressInfo } from 'node:net'; + +import Fastify from 'fastify'; +import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from 'vitest'; + +import { clerkPlugin } from '../index'; + +// End-to-end through the real @clerk/backend. A local Fastify instance stands in for the +// Backend API so the tests can observe whether a handshake payload exchange happens. +const PK_LIVE = 'pk_live_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA'; +const PK_TEST = 'pk_test_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA'; + +const payloadRequests: string[] = []; +const fakeBackendApi = Fastify(); +fakeBackendApi.get('/v1/clients/handshake_payload', (request, reply) => { + payloadRequests.push(request.url); + reply.code(404).send({ errors: [{ code: 'resource_not_found', message: 'not found' }] }); +}); + +let apiUrl = ''; + +beforeAll(async () => { + await fakeBackendApi.listen({ port: 0, host: '127.0.0.1' }); + apiUrl = `http://127.0.0.1:${(fakeBackendApi.server.address() as AddressInfo).port}`; +}); + +afterAll(async () => { + await fakeBackendApi.close(); +}); + +afterEach(() => { + payloadRequests.length = 0; + vi.restoreAllMocks(); +}); + +const buildApp = async (pluginOptions: Record) => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const app = Fastify(); + await app.register(clerkPlugin, { apiUrl, ...pluginOptions }); + app.get('/api/me', (_request, reply) => { + reply.send({ + status: reply.getHeader('x-clerk-auth-status'), + reason: reply.getHeader('x-clerk-auth-reason'), + }); + }); + return app; +}; + +const production = { secretKey: 'sk_live_deadbeef', publishableKey: PK_LIVE }; +const development = { secretKey: 'sk_test_deadbeef', publishableKey: PK_TEST }; + +const fetchHeaders = { 'sec-fetch-dest': 'empty', accept: '*/*' }; +const navigationHeaders = { 'sec-fetch-dest': 'document', accept: 'text/html' }; + +describe('clerkPlugin handshake handling (real @clerk/backend)', () => { + test('by default a stale nonce on a fetch request is exchanged with the Backend API', async () => { + const app = await buildApp(production); + + const response = await app.inject({ + method: 'GET', + url: '/api/me', + headers: { ...fetchHeaders, cookie: '__clerk_handshake_nonce=stale; __client_uat=12345' }, + }); + + expect(response.statusCode).toBe(200); + expect(payloadRequests).toHaveLength(1); + expect(response.json()).toEqual({ status: 'signed-out', reason: 'session-token-missing' }); + }); + + test('with __internal_enableHandshake: false a stale nonce on a fetch request is ignored', async () => { + const app = await buildApp({ ...production, __internal_enableHandshake: false }); + + const response = await app.inject({ + method: 'GET', + url: '/api/me', + headers: { ...fetchHeaders, cookie: '__clerk_handshake_nonce=stale; __client_uat=12345' }, + }); + + expect(response.statusCode).toBe(200); + expect(payloadRequests).toHaveLength(0); + expect(response.json()).toEqual({ status: 'signed-out', reason: 'client-uat-but-no-session-token' }); + }); + + test('with __internal_enableHandshake: false a stale nonce on a POST request is ignored', async () => { + const app = await buildApp({ ...production, __internal_enableHandshake: false }); + app.post('/api/submit', (_request, reply) => { + reply.send({ status: reply.getHeader('x-clerk-auth-status') }); + }); + + const response = await app.inject({ + method: 'POST', + url: '/api/submit', + headers: { ...navigationHeaders, cookie: '__clerk_handshake_nonce=stale; __client_uat=12345' }, + }); + + expect(response.statusCode).toBe(200); + expect(payloadRequests).toHaveLength(0); + expect(response.json()).toEqual({ status: 'signed-out' }); + }); + + test('with __internal_enableHandshake: false a development navigation still redirects to the dev browser handshake', async () => { + const app = await buildApp({ ...development, __internal_enableHandshake: false }); + + const response = await app.inject({ method: 'GET', url: '/api/me', headers: navigationHeaders }); + + expect(response.statusCode).toBe(307); + expect(response.headers.location).toContain('/v1/client/handshake'); + expect(response.headers['x-clerk-auth-reason']).toBe('dev-browser-missing'); + expect(payloadRequests).toHaveLength(0); + }); + + test('with __internal_enableHandshake: false a development navigation returning from the handshake still resolves the nonce', async () => { + const app = await buildApp({ ...development, __internal_enableHandshake: false }); + + const response = await app.inject({ + method: 'GET', + url: '/api/me?__clerk_handshake_nonce=fresh', + headers: navigationHeaders, + }); + + expect(payloadRequests).toHaveLength(1); + expect(payloadRequests[0]).toContain('nonce=fresh'); + // Development resolution redirects to the same URL with the handshake params removed. + expect(response.statusCode).toBe(307); + expect(response.headers.location).not.toContain('__clerk_handshake_nonce'); + }); +}); diff --git a/packages/fastify/src/__tests__/withClerkMiddleware.test.ts b/packages/fastify/src/__tests__/withClerkMiddleware.test.ts index 191e75d7254..e9103039be1 100644 --- a/packages/fastify/src/__tests__/withClerkMiddleware.test.ts +++ b/packages/fastify/src/__tests__/withClerkMiddleware.test.ts @@ -315,99 +315,6 @@ describe('withClerkMiddleware(options)', () => { ); }); - test('skips handshake redirect when __internal_enableHandshake is false', async () => { - authenticateRequestMock.mockResolvedValueOnce({ - status: 'handshake', - reason: 'session-token-expired', - headers: new Headers({ - location: 'https://fapi.example.com/v1/clients/handshake', - 'x-clerk-auth-status': 'handshake', - 'cache-control': 'no-store', - }), - toAuth: () => ({ tokenType: 'session_token' }), - }); - const fastify = Fastify(); - await fastify.register(clerkPlugin, { __internal_enableHandshake: false }); - - fastify.get('/', (request: FastifyRequest, reply: FastifyReply) => { - const auth = getAuth(request); - reply.send({ auth }); - }); - - const response = await fastify.inject({ - method: 'GET', - path: '/', - headers: { - cookie: '__clerk_handshake_nonce=deadbeef; __client_uat=1675692233', - }, - }); - - expect(response.statusCode).toEqual(200); - expect(response.headers.location).toBeUndefined(); - expect(response.headers['cache-control']).toBeUndefined(); - expect(response.body).toEqual(JSON.stringify({ auth: { tokenType: 'session_token' } })); - }); - - test('falls back to a signed-out auth object when a skipped handshake state has a null auth', async () => { - authenticateRequestMock.mockResolvedValueOnce({ - status: 'handshake', - reason: 'session-token-expired', - headers: new Headers({ - location: 'https://fapi.example.com/v1/clients/handshake', - 'x-clerk-auth-status': 'handshake', - }), - toAuth: () => null, - }); - const fastify = Fastify(); - await fastify.register(clerkPlugin, { __internal_enableHandshake: false }); - - fastify.get('/', (request: FastifyRequest, reply: FastifyReply) => { - const auth = getAuth(request); - reply.send({ userId: auth.userId, isAuthenticated: auth.isAuthenticated }); - }); - - const response = await fastify.inject({ - method: 'GET', - path: '/', - headers: { cookie: '__client_uat=1675692233' }, - }); - - expect(response.statusCode).toEqual(200); - expect(response.headers.location).toBeUndefined(); - expect(response.body).toEqual(JSON.stringify({ userId: null, isAuthenticated: false })); - }); - - test.each(['dev-browser-missing', 'dev-browser-sync'])( - 'still redirects for %s handshake even when __internal_enableHandshake is false', - async reason => { - authenticateRequestMock.mockResolvedValueOnce({ - status: 'handshake', - reason, - headers: new Headers({ - location: 'https://fapi.example.com/v1/clients/handshake', - 'x-clerk-auth-status': 'handshake', - 'x-clerk-auth-reason': reason, - }), - toAuth: () => null, - }); - const fastify = Fastify(); - await fastify.register(clerkPlugin, { __internal_enableHandshake: false }); - - fastify.get('/', (_request: FastifyRequest, reply: FastifyReply) => { - reply.send({}); - }); - - const response = await fastify.inject({ - method: 'GET', - path: '/', - headers: { cookie: '__client_uat=1675692233' }, - }); - - expect(response.statusCode).toEqual(307); - expect(response.headers.location).toEqual('https://fapi.example.com/v1/clients/handshake'); - }, - ); - test('asks @clerk/backend to resolve handshakes only for navigation when __internal_enableHandshake is false', async () => { authenticateRequestMock.mockResolvedValueOnce({ headers: new Headers(), diff --git a/packages/fastify/src/types.ts b/packages/fastify/src/types.ts index 45c4dca7ad4..c1296c98bde 100644 --- a/packages/fastify/src/types.ts +++ b/packages/fastify/src/types.ts @@ -31,11 +31,10 @@ export type ClerkFastifyOptions = ClerkOptions & { * Whether to enable the handshake flow for session verification. * * When set to `false`, handshake cookies and query params are ignored on requests that - * are not eligible for a handshake redirect, and handshake redirects are skipped (except - * dev-browser handshakes, which development instances require). Intended for pure API - * backends (e.g. a SPA calling a Fastify server) where the server cannot deliver - * `Set-Cookie` headers back to the browser, so stale handshake nonces would otherwise be - * replayed and trigger repeated `404` errors from the Frontend API. + * are not eligible for a handshake redirect. Intended for pure API backends (e.g. a SPA + * calling a Fastify server) where the server cannot deliver `Set-Cookie` headers back to + * the browser, so stale handshake nonces would otherwise be replayed and trigger repeated + * `404` errors from the Frontend API. * * @internal * @default true diff --git a/packages/fastify/src/withClerkMiddleware.ts b/packages/fastify/src/withClerkMiddleware.ts index 765bd73f771..774016ef5e6 100644 --- a/packages/fastify/src/withClerkMiddleware.ts +++ b/packages/fastify/src/withClerkMiddleware.ts @@ -1,5 +1,5 @@ import { createClerkClient } from '@clerk/backend'; -import { AuthStatus, signedOutAuthObject } from '@clerk/backend/internal'; +import { AuthStatus } from '@clerk/backend/internal'; import { clerkFrontendApiProxy, DEFAULT_PROXY_PATH, stripTrailingSlashes } from '@clerk/backend/proxy'; import { apiUrlFromPublishableKey } from '@clerk/shared/apiUrlFromPublishableKey'; import type { FastifyReply, FastifyRequest } from 'fastify'; @@ -116,22 +116,13 @@ export const withClerkMiddleware = (options: ClerkFastifyOptions) => { const locationHeader = requestState.headers.get(constants.Headers.Location); if (locationHeader) { - // Development instances cannot establish auth state without the dev browser handshake. - const isDevBrowserHandshake = - requestState.reason === 'dev-browser-missing' || requestState.reason === 'dev-browser-sync'; - if (enableHandshake || isDevBrowserHandshake) { - return reply.code(307).send(); - } - reply.removeHeader(constants.Headers.Location); - reply.removeHeader(constants.Headers.CacheControl); - } else if (enableHandshake && requestState.status === AuthStatus.Handshake) { + return reply.code(307).send(); + } else if (requestState.status === AuthStatus.Handshake) { throw new Error('Clerk: handshake status without redirect'); } - // A skipped handshake redirect leaves a handshake state whose toAuth() is null. // @ts-expect-error Inject auth so getAuth can read it - fastifyRequest.auth = - requestState.toAuth() ?? signedOutAuthObject({ reason: requestState.reason, message: requestState.message }); + fastifyRequest.auth = requestState.toAuth(); fastifyRequest.clerk = clerkClient; }; }; From 8cfe459a0e0334ebbfe8f98cae29c88e9a1f1291 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 1 Sep 2026 22:01:13 -0700 Subject: [PATCH 8/9] chore(repo): Shorten handshake changesets Claude-Session: https://claude.ai/code/session_014TUNghY1SiahtRY5t1zZWC --- .changeset/backend-internal-enable-handshake.md | 2 +- .changeset/fastify-delegate-handshake-opt-out.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/backend-internal-enable-handshake.md b/.changeset/backend-internal-enable-handshake.md index c06095a73d7..9a44821ef13 100644 --- a/.changeset/backend-internal-enable-handshake.md +++ b/.changeset/backend-internal-enable-handshake.md @@ -2,4 +2,4 @@ '@clerk/backend': patch --- -Add an internal `__internal_resolveHandshakeOnlyForNavigation` option to `authenticateRequest()`, off by default. When it's on, requests that are not eligible for a handshake redirect ignore handshake cookies and query params instead of exchanging them with the Backend API. Page navigations behave exactly as before. Turn it on for an API-only backend sitting behind a proxy that drops `Set-Cookie`, where a stale handshake nonce would otherwise cost a failing Backend API call on every request. +Add internal `__internal_resolveHandshakeOnlyForNavigation` option to `authenticateRequest()`. diff --git a/.changeset/fastify-delegate-handshake-opt-out.md b/.changeset/fastify-delegate-handshake-opt-out.md index dfe56bd7a32..8bd0c21cebb 100644 --- a/.changeset/fastify-delegate-handshake-opt-out.md +++ b/.changeset/fastify-delegate-handshake-opt-out.md @@ -2,4 +2,4 @@ '@clerk/fastify': patch --- -`clerkPlugin()` with `__internal_enableHandshake: false` now relies on `@clerk/backend` to ignore stale handshake cookies on requests that cannot complete a handshake, instead of stripping them itself. Handshake redirects are no longer suppressed for the rare navigation request that reaches an API-only backend; they behave the same as in every other Clerk SDK. +`__internal_enableHandshake` now delegates to `@clerk/backend`. From 419447375a138c4bfeba92cab9bd70b8f18f970e Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 1 Sep 2026 22:05:29 -0700 Subject: [PATCH 9/9] refactor(fastify): Tighten handshake test types and drop redundant variable Claude-Session: https://claude.ai/code/session_014TUNghY1SiahtRY5t1zZWC --- .../src/__tests__/withClerkMiddleware.handshake.test.ts | 6 +++--- packages/fastify/src/withClerkMiddleware.ts | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/fastify/src/__tests__/withClerkMiddleware.handshake.test.ts b/packages/fastify/src/__tests__/withClerkMiddleware.handshake.test.ts index 6a3ecab91cc..67929abeb4b 100644 --- a/packages/fastify/src/__tests__/withClerkMiddleware.handshake.test.ts +++ b/packages/fastify/src/__tests__/withClerkMiddleware.handshake.test.ts @@ -4,9 +4,9 @@ import Fastify from 'fastify'; import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from 'vitest'; import { clerkPlugin } from '../index'; +import type { ClerkFastifyOptions } from '../types'; -// End-to-end through the real @clerk/backend. A local Fastify instance stands in for the -// Backend API so the tests can observe whether a handshake payload exchange happens. +// Runs the real @clerk/backend against a local stand-in for the Backend API. const PK_LIVE = 'pk_live_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA'; const PK_TEST = 'pk_test_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA'; @@ -33,7 +33,7 @@ afterEach(() => { vi.restoreAllMocks(); }); -const buildApp = async (pluginOptions: Record) => { +const buildApp = async (pluginOptions: Partial) => { vi.spyOn(console, 'error').mockImplementation(() => {}); const app = Fastify(); await app.register(clerkPlugin, { apiUrl, ...pluginOptions }); diff --git a/packages/fastify/src/withClerkMiddleware.ts b/packages/fastify/src/withClerkMiddleware.ts index 774016ef5e6..a2c9c557762 100644 --- a/packages/fastify/src/withClerkMiddleware.ts +++ b/packages/fastify/src/withClerkMiddleware.ts @@ -11,7 +11,6 @@ import { fastifyRequestToRequest, requestToProxyRequest } from './utils'; export const withClerkMiddleware = (options: ClerkFastifyOptions) => { const { hookName: _hookName, frontendApiProxy, __internal_enableHandshake, ...clerkOptions } = options; - const enableHandshake = __internal_enableHandshake ?? true; const proxyPath = stripTrailingSlashes(frontendApiProxy?.path ?? DEFAULT_PROXY_PATH) || DEFAULT_PROXY_PATH; const publishableKey = options.publishableKey || constants.PUBLISHABLE_KEY; const secretKey = options.secretKey || constants.SECRET_KEY; @@ -109,7 +108,7 @@ export const withClerkMiddleware = (options: ClerkFastifyOptions) => { publishableKey, proxyUrl: resolvedProxyUrl, acceptsToken: 'any', - __internal_resolveHandshakeOnlyForNavigation: !enableHandshake, + __internal_resolveHandshakeOnlyForNavigation: __internal_enableHandshake === false, }); requestState.headers.forEach((value, key) => reply.header(key, value));