From 815b7c0b9561574b3ded73e219b28b601592b87d Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 1 Sep 2026 09:55:53 -0700 Subject: [PATCH 1/2] fix(fastify): Add __internal_enableHandshake option to skip handshake flow When disabled, the plugin strips handshake cookies and query params before authenticating and skips handshake redirects, except dev-browser handshakes that development instances require. A skipped handshake redirect now falls back to a signed-out auth object instead of a null request.auth, and the handshake location/cache-control headers are dropped from the reply. Claude-Session: https://claude.ai/code/session_01KHSkuMqXon3pG5c7ifFTN2 --- .../fastify-internal-enable-handshake.md | 5 + .../src/__tests__/withClerkMiddleware.test.ts | 147 ++++++++++++++++++ packages/fastify/src/types.ts | 15 ++ packages/fastify/src/withClerkMiddleware.ts | 52 ++++++- 4 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 .changeset/fastify-internal-enable-handshake.md diff --git a/.changeset/fastify-internal-enable-handshake.md b/.changeset/fastify-internal-enable-handshake.md new file mode 100644 index 00000000000..8b05e09fbb9 --- /dev/null +++ b/.changeset/fastify-internal-enable-handshake.md @@ -0,0 +1,5 @@ +--- +'@clerk/fastify': patch +--- + +Add an internal `__internal_enableHandshake` option to `clerkPlugin()` (defaults to `true`). When set to `false`, the plugin skips the handshake flow and strips handshake cookies and query params before authenticating requests. Intended for API-only backends that cannot return `Set-Cookie` headers to the browser. diff --git a/packages/fastify/src/__tests__/withClerkMiddleware.test.ts b/packages/fastify/src/__tests__/withClerkMiddleware.test.ts index b9fda7b8a4e..0eb86d41560 100644 --- a/packages/fastify/src/__tests__/withClerkMiddleware.test.ts +++ b/packages/fastify/src/__tests__/withClerkMiddleware.test.ts @@ -314,4 +314,151 @@ 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('strips handshake cookies and query params before authenticating when __internal_enableHandshake is false', async () => { + authenticateRequestMock.mockResolvedValueOnce({ + headers: new Headers(), + toAuth: () => ({ tokenType: 'session_token' }), + }); + const fastify = Fastify(); + await fastify.register(clerkPlugin, { __internal_enableHandshake: false }); + + fastify.get('/', (_request: FastifyRequest, reply: FastifyReply) => { + reply.send({}); + }); + + 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', + }, + }); + + 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'); + }); + + test('does not strip handshake cookies or query params by default', async () => { + authenticateRequestMock.mockResolvedValueOnce({ + headers: new Headers(), + toAuth: () => ({ tokenType: 'session_token' }), + }); + const fastify = Fastify(); + await fastify.register(clerkPlugin); + + fastify.get('/', (_request: FastifyRequest, reply: FastifyReply) => { + reply.send({}); + }); + + await fastify.inject({ + method: 'GET', + path: '/?__clerk_handshake=token123', + headers: { + cookie: '__clerk_handshake_nonce=nonce456; __client_uat=1675692233', + }, + }); + + 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'); + }); }); diff --git a/packages/fastify/src/types.ts b/packages/fastify/src/types.ts index 7335800f085..2b02c7eaf48 100644 --- a/packages/fastify/src/types.ts +++ b/packages/fastify/src/types.ts @@ -27,4 +27,19 @@ export interface FrontendApiProxyOptions { export type ClerkFastifyOptions = ClerkOptions & { hookName?: (typeof ALLOWED_HOOKS)[number]; frontendApiProxy?: FrontendApiProxyOptions; + /** + * 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 + * 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 + */ + __internal_enableHandshake?: boolean; }; diff --git a/packages/fastify/src/withClerkMiddleware.ts b/packages/fastify/src/withClerkMiddleware.ts index 2212b23beb9..81795add3d7 100644 --- a/packages/fastify/src/withClerkMiddleware.ts +++ b/packages/fastify/src/withClerkMiddleware.ts @@ -1,5 +1,5 @@ import { createClerkClient } from '@clerk/backend'; -import { AuthStatus } from '@clerk/backend/internal'; +import { AuthStatus, signedOutAuthObject } 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'; @@ -9,8 +9,35 @@ import * as constants from './constants'; import type { ClerkFastifyOptions } from './types'; import { fastifyRequestToRequest, requestToProxyRequest } from './utils'; +// Handshake cookies and query params share the same names (`QueryParameters` aliases `Cookies` in `@clerk/backend`). +function 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 }); +} + export const withClerkMiddleware = (options: ClerkFastifyOptions) => { - const { hookName: _hookName, frontendApiProxy, ...clerkOptions } = options; + 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; @@ -102,8 +129,12 @@ 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, { - ...options, + ...clerkOptions, secretKey, publishableKey, proxyUrl: resolvedProxyUrl, @@ -114,13 +145,22 @@ export const withClerkMiddleware = (options: ClerkFastifyOptions) => { const locationHeader = requestState.headers.get(constants.Headers.Location); if (locationHeader) { - return reply.code(307).send(); - } else if (requestState.status === AuthStatus.Handshake) { + // 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) { 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(); + fastifyRequest.auth = + requestState.toAuth() ?? signedOutAuthObject({ reason: requestState.reason, message: requestState.message }); fastifyRequest.clerk = clerkClient; }; }; From 386bcffcd425405f470b0479a7a947307e15a7e5 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 1 Sep 2026 10:08:26 -0700 Subject: [PATCH 2/2] refactor(fastify): Move stripHandshakeCookiesAndParams to utils Claude-Session: https://claude.ai/code/session_01KHSkuMqXon3pG5c7ifFTN2 --- packages/fastify/src/utils.ts | 30 +++++++++++++++++++++ packages/fastify/src/withClerkMiddleware.ts | 28 +------------------ 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/packages/fastify/src/utils.ts b/packages/fastify/src/utils.ts index 1b36da0ef9b..3754add80b2 100644 --- a/packages/fastify/src/utils.ts +++ b/packages/fastify/src/utils.ts @@ -61,3 +61,33 @@ 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 81795add3d7..20b177e76a9 100644 --- a/packages/fastify/src/withClerkMiddleware.ts +++ b/packages/fastify/src/withClerkMiddleware.ts @@ -7,33 +7,7 @@ import { Readable } from 'stream'; import * as constants from './constants'; import type { ClerkFastifyOptions } from './types'; -import { fastifyRequestToRequest, requestToProxyRequest } from './utils'; - -// Handshake cookies and query params share the same names (`QueryParameters` aliases `Cookies` in `@clerk/backend`). -function 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 }); -} +import { fastifyRequestToRequest, requestToProxyRequest, stripHandshakeCookiesAndParams } from './utils'; export const withClerkMiddleware = (options: ClerkFastifyOptions) => { const { hookName: _hookName, frontendApiProxy, __internal_enableHandshake, ...clerkOptions } = options;