diff --git a/.changeset/three-cameras-decide.md b/.changeset/three-cameras-decide.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/three-cameras-decide.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cab3138702e..da6b80dcfed 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -292,6 +292,7 @@ jobs:
[
"generic",
"express",
+ "fastify",
"ap-flows",
"localhost",
"sessions",
diff --git a/integration/presets/fastify.ts b/integration/presets/fastify.ts
new file mode 100644
index 00000000000..6de2cc43448
--- /dev/null
+++ b/integration/presets/fastify.ts
@@ -0,0 +1,19 @@
+import { applicationConfig } from '../models/applicationConfig';
+import { templates } from '../templates';
+import { PKGLAB } from './utils';
+
+const vite = applicationConfig()
+ .setName('fastify-vite')
+ .useTemplate(templates['fastify-vite'])
+ .setEnvFormatter('public', key => `VITE_${key}`)
+ .addScript('setup', 'pnpm install')
+ .addScript('dev', 'pnpm dev')
+ .addScript('build', 'pnpm build')
+ .addScript('serve', 'pnpm start')
+ .addDependency('@clerk/fastify', PKGLAB)
+ .addDependency('@clerk/clerk-js', PKGLAB)
+ .addDependency('@clerk/ui', PKGLAB);
+
+export const fastify = {
+ vite,
+} as const;
diff --git a/integration/presets/index.ts b/integration/presets/index.ts
index 246dfbac48e..83c27057a82 100644
--- a/integration/presets/index.ts
+++ b/integration/presets/index.ts
@@ -3,6 +3,7 @@ import { customFlows } from './custom-flows';
import { envs, instanceKeys } from './envs';
import { expo } from './expo';
import { express } from './express';
+import { fastify } from './fastify';
import { hono } from './hono';
import { createLongRunningApps } from './longRunningApps';
import { next } from './next';
@@ -16,6 +17,7 @@ export const appConfigs = {
customFlows,
envs,
express,
+ fastify,
hono,
longRunningApps: createLongRunningApps(),
next,
diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts
index 9f4caa4b16d..3da5e75c82f 100644
--- a/integration/presets/longRunningApps.ts
+++ b/integration/presets/longRunningApps.ts
@@ -4,6 +4,7 @@ import { astro } from './astro';
import { envs } from './envs';
import { expo } from './expo';
import { express } from './express';
+import { fastify } from './fastify';
import { hono } from './hono';
import { next } from './next';
import { nuxt } from './nuxt';
@@ -82,6 +83,12 @@ export const createLongRunningApps = () => {
{ id: 'react-router.node', config: reactRouter.reactRouterNode, env: envs.withEmailCodes },
{ id: 'express.vite.withEmailCodes', config: express.vite, env: envs.withEmailCodes },
+ /**
+ * Fastify apps
+ */
+ { id: 'fastify.vite.withEmailCodes', config: fastify.vite, env: envs.withEmailCodes },
+ { id: 'fastify.vite.withEmailCodesProxy', config: fastify.vite, env: envs.withEmailCodesProxy },
+
/**
* Hono apps
*/
diff --git a/integration/templates/fastify-vite/index.html b/integration/templates/fastify-vite/index.html
new file mode 100644
index 00000000000..a38207521b9
--- /dev/null
+++ b/integration/templates/fastify-vite/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + TS + Fastify
+
+
+
+
+
+
diff --git a/integration/templates/fastify-vite/package.json b/integration/templates/fastify-vite/package.json
new file mode 100644
index 00000000000..b4a73f5276d
--- /dev/null
+++ b/integration/templates/fastify-vite/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "fastify-vite",
+ "version": "0.0.0",
+ "private": true,
+ "scripts": {
+ "build": "vite build",
+ "dev": "PORT=$PORT tsx src/server/main.ts",
+ "preview": "vite preview --port $PORT --no-open",
+ "start": "PORT=$PORT NODE_ENV=production tsx src/server/main.ts"
+ },
+ "dependencies": {
+ "dotenv": "^17.2.1",
+ "express": "^5.1.0",
+ "fastify": "^5.7.2",
+ "fastify-plugin": "^5.0.1",
+ "tsx": "^4.20.3",
+ "vite-express": "^0.21.1"
+ },
+ "devDependencies": {
+ "@types/express": "^5.0.3",
+ "@types/node": "^24.2.1",
+ "typescript": "^5.8.3",
+ "vite": "^6.3.3"
+ }
+}
diff --git a/integration/templates/fastify-vite/src/client/main.ts b/integration/templates/fastify-vite/src/client/main.ts
new file mode 100644
index 00000000000..7dcc4eb0a36
--- /dev/null
+++ b/integration/templates/fastify-vite/src/client/main.ts
@@ -0,0 +1,30 @@
+import { Clerk } from '@clerk/clerk-js';
+import { ClerkUI } from '@clerk/ui/entry';
+
+const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
+
+document.addEventListener('DOMContentLoaded', async function () {
+ const clerk = new Clerk(publishableKey);
+
+ await clerk.load({
+ ui: { ClerkUI },
+ });
+
+ if (clerk.isSignedIn) {
+ document.getElementById('app')!.innerHTML = `
+
+ `;
+
+ const userButtonDiv = document.getElementById('user-button');
+
+ clerk.mountUserButton(userButtonDiv);
+ } else {
+ document.getElementById('app')!.innerHTML = `
+
+ `;
+
+ const signInDiv = document.getElementById('sign-in');
+
+ clerk.mountSignIn(signInDiv);
+ }
+});
diff --git a/integration/templates/fastify-vite/src/client/tsconfig.json b/integration/templates/fastify-vite/src/client/tsconfig.json
new file mode 100644
index 00000000000..e659ea0c37b
--- /dev/null
+++ b/integration/templates/fastify-vite/src/client/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "module": "ESNext",
+ "moduleResolution": "Bundler"
+ }
+}
diff --git a/integration/templates/fastify-vite/src/client/vite-env.d.ts b/integration/templates/fastify-vite/src/client/vite-env.d.ts
new file mode 100644
index 00000000000..11f02fe2a00
--- /dev/null
+++ b/integration/templates/fastify-vite/src/client/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/integration/templates/fastify-vite/src/server/main.ts b/integration/templates/fastify-vite/src/server/main.ts
new file mode 100644
index 00000000000..800bb0bb3c3
--- /dev/null
+++ b/integration/templates/fastify-vite/src/server/main.ts
@@ -0,0 +1,67 @@
+import 'dotenv/config';
+
+import { clerkPlugin, getAuth } from '@clerk/fastify';
+import express from 'express';
+import Fastify from 'fastify';
+import ViteExpress from 'vite-express';
+
+async function start() {
+ const fastify = Fastify();
+
+ const proxyEnabled = process.env.CLERK_PROXY_ENABLED === 'true';
+
+ fastify.register(clerkPlugin, {
+ publishableKey: process.env.VITE_CLERK_PUBLISHABLE_KEY,
+ ...(proxyEnabled ? { frontendApiProxy: { enabled: true } } : {}),
+ });
+
+ fastify.get('/protected', async (request, reply) => {
+ const { userId } = getAuth(request);
+ if (!userId) {
+ return reply.code(401).send('Unauthorized');
+ }
+
+ return reply.send('Protected API response');
+ });
+
+ // Start Fastify on an internal port, then bridge /api requests from Express
+ await fastify.listen({ port: 0, host: '127.0.0.1' });
+ const fastifyAddress = fastify.server.address();
+ const fastifyPort = typeof fastifyAddress === 'object' ? fastifyAddress?.port : 0;
+
+ const expressApp = express();
+
+ // Proxy /api requests to Fastify
+ expressApp.use('/api', async (req: any, res: any) => {
+ const url = `http://127.0.0.1:${fastifyPort}${req.url}`;
+ const headers: Record = {};
+ for (const [key, value] of Object.entries(req.headers)) {
+ if (typeof value === 'string') {
+ headers[key] = value;
+ } else if (Array.isArray(value)) {
+ headers[key] = value.join(', ');
+ }
+ }
+
+ const response = await fetch(url, {
+ method: req.method,
+ headers,
+ body: ['GET', 'HEAD'].includes(req.method) ? undefined : req,
+ // @ts-expect-error duplex needed for streaming request bodies
+ duplex: ['GET', 'HEAD'].includes(req.method) ? undefined : 'half',
+ redirect: 'manual',
+ });
+
+ res.status(response.status);
+ response.headers.forEach((value: string, key: string) => {
+ res.setHeader(key, value);
+ });
+ const body = await response.arrayBuffer();
+ res.send(Buffer.from(body));
+ });
+
+ const port = parseInt(process.env.PORT as string) || 3002;
+ ViteExpress.listen(expressApp, port, () => console.log(`Server is listening on port ${port}...`));
+}
+
+start();
diff --git a/integration/templates/fastify-vite/tsconfig.json b/integration/templates/fastify-vite/tsconfig.json
new file mode 100644
index 00000000000..3df50bf37ec
--- /dev/null
+++ b/integration/templates/fastify-vite/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "module": "NodeNext",
+ "lib": ["ESNext", "DOM"],
+ "moduleResolution": "NodeNext",
+ "strict": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "noEmit": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noImplicitReturns": true,
+ "skipLibCheck": true
+ },
+ "include": ["src"]
+}
diff --git a/integration/templates/index.ts b/integration/templates/index.ts
index 81329df1aa7..d073d7fa58b 100644
--- a/integration/templates/index.ts
+++ b/integration/templates/index.ts
@@ -12,6 +12,7 @@ export const templates = {
'react-cra': resolve(__dirname, './react-cra'),
'react-vite': resolve(__dirname, './react-vite'),
'express-vite': resolve(__dirname, './express-vite'),
+ 'fastify-vite': resolve(__dirname, './fastify-vite'),
'hono-vite': resolve(__dirname, './hono-vite'),
'elements-next': resolve(__dirname, './elements-next'),
'astro-node': resolve(__dirname, './astro-node'),
diff --git a/integration/tests/fastify/basic.test.ts b/integration/tests/fastify/basic.test.ts
new file mode 100644
index 00000000000..c33e6061071
--- /dev/null
+++ b/integration/tests/fastify/basic.test.ts
@@ -0,0 +1,53 @@
+import { expect, test } from '@playwright/test';
+
+import { appConfigs } from '../../presets';
+import type { FakeUser } from '../../testUtils';
+import { createTestUtils, testAgainstRunningApps } from '../../testUtils';
+
+testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('basic tests for @fastify', ({ app }) => {
+ test.describe.configure({ mode: 'parallel' });
+
+ let fakeUser: FakeUser;
+
+ test.beforeAll(async () => {
+ const u = createTestUtils({ app });
+ fakeUser = u.services.users.createFakeUser();
+ await u.services.users.createBapiUser(fakeUser);
+ });
+
+ test.afterAll(async () => {
+ await fakeUser.deleteIfExists();
+
+ await app.teardown();
+ });
+
+ test('authenticates protected routes when user is signed in using getAuth()', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.page.goToRelative('/');
+
+ await u.po.signIn.waitForMounted();
+ await u.po.signIn.setIdentifier(fakeUser.email);
+ await u.po.signIn.continue();
+ await u.po.signIn.setPassword(fakeUser.password);
+ await u.po.signIn.continue();
+
+ await u.po.userButton.waitForMounted();
+
+ const url = new URL('/api/protected', app.serverUrl);
+ const res = await u.page.request.get(url.toString());
+ expect(res.status()).toBe(200);
+ expect(await res.text()).toBe('Protected API response');
+ });
+
+ test('rejects protected routes when user is not authenticated using getAuth()', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.page.goToRelative('/');
+
+ await u.po.signIn.waitForMounted();
+
+ const url = new URL('/api/protected', app.serverUrl);
+ const res = await u.page.request.get(url.toString());
+ expect(res.status()).toBe(401);
+ expect(await res.text()).toBe('Unauthorized');
+ });
+});
diff --git a/integration/tests/fastify/proxy.test.ts b/integration/tests/fastify/proxy.test.ts
new file mode 100644
index 00000000000..84f6de0818a
--- /dev/null
+++ b/integration/tests/fastify/proxy.test.ts
@@ -0,0 +1,84 @@
+import { expect, test } from '@playwright/test';
+
+import { appConfigs } from '../../presets';
+import type { FakeUser } from '../../testUtils';
+import { createTestUtils, testAgainstRunningApps } from '../../testUtils';
+
+testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodesProxy] })(
+ 'frontend API proxy tests for @fastify',
+ ({ app }) => {
+ test.describe.configure({ mode: 'parallel' });
+
+ let fakeUser: FakeUser;
+
+ test.beforeAll(async () => {
+ const u = createTestUtils({ app });
+ fakeUser = u.services.users.createFakeUser();
+ await u.services.users.createBapiUser(fakeUser);
+ });
+
+ test.afterAll(async () => {
+ await fakeUser.deleteIfExists();
+ await app.teardown();
+ });
+
+ test('protected routes still require auth when proxy is enabled', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.page.goToRelative('/');
+ await u.po.signIn.waitForMounted();
+
+ const url = new URL('/api/protected', app.serverUrl);
+ const res = await u.page.request.get(url.toString());
+ expect(res.status()).toBe(401);
+ expect(await res.text()).toBe('Unauthorized');
+ });
+
+ test('authenticated requests work with proxy enabled', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.page.goToRelative('/');
+
+ await u.po.signIn.waitForMounted();
+ await u.po.signIn.setIdentifier(fakeUser.email);
+ await u.po.signIn.continue();
+ await u.po.signIn.setPassword(fakeUser.password);
+ await u.po.signIn.continue();
+
+ await u.po.userButton.waitForMounted();
+
+ const url = new URL('/api/protected', app.serverUrl);
+ const res = await u.page.request.get(url.toString());
+ expect(res.status()).toBe(200);
+ expect(await res.text()).toBe('Protected API response');
+ });
+
+ test('handshake redirect uses forwarded headers for proxyUrl, not localhost', async () => {
+ // This test proves that the SDK must derive proxyUrl from x-forwarded-* headers.
+ // When a reverse proxy sits in front of the app, the raw request URL is localhost,
+ // but the handshake redirect must point to the public origin.
+ //
+ // We simulate a behind-proxy scenario by sending x-forwarded-proto and x-forwarded-host
+ // headers, with a __client_uat cookie (non-zero) but no session cookie, which forces
+ // a handshake. The handshake redirect Location should use the forwarded origin.
+ const url = new URL('/api/protected', app.serverUrl);
+ const res = await fetch(url.toString(), {
+ headers: {
+ 'x-forwarded-proto': 'https',
+ 'x-forwarded-host': 'myapp.example.com',
+ 'sec-fetch-dest': 'document',
+ Accept: 'text/html',
+ Cookie: '__clerk_db_jwt=needstobeset; __client_uat=1',
+ },
+ redirect: 'manual',
+ });
+
+ // The server should respond with a 307 handshake redirect
+ expect(res.status).toBe(307);
+ const location = res.headers.get('location') ?? '';
+ // The redirect must point to the public origin (from forwarded headers),
+ // NOT to http://localhost:PORT. If the SDK uses requestUrl.origin instead
+ // of forwarded headers, this assertion will fail.
+ expect(location).toContain('https://myapp.example.com');
+ expect(location).not.toContain('localhost');
+ });
+ },
+);
diff --git a/package.json b/package.json
index 900b864776b..920a4b4eae3 100644
--- a/package.json
+++ b/package.json
@@ -44,6 +44,7 @@
"test:integration:deployment:nextjs": "pnpm playwright test --config integration/playwright.deployments.config.ts",
"test:integration:expo-web:disabled": "E2E_APP_ID=expo.expo-web pnpm test:integration:base --grep @expo-web",
"test:integration:express": "E2E_APP_ID=express.* pnpm test:integration:base --grep @express",
+ "test:integration:fastify": "E2E_APP_ID=fastify.* pnpm test:integration:base --grep @fastify",
"test:integration:generic": "E2E_APP_ID=react.vite.*,next.appRouter.withEmailCodes* pnpm test:integration:base --grep @generic",
"test:integration:handshake": "DISABLE_WEB_SECURITY=true E2E_APP_1_ENV_KEY=sessions-prod-1 E2E_SESSIONS_APP_1_HOST=multiple-apps-e2e.clerk.app pnpm test:integration:base --grep @handshake",
"test:integration:handshake:staging": "DISABLE_WEB_SECURITY=true E2E_APP_1_ENV_KEY=clerkstage-sessions-prod-1 E2E_SESSIONS_APP_1_HOST=clerkstage-sessions-prod-1-e2e.clerk.app pnpm test:integration:base --grep @handshake",