Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/backend-proxy-forwarded-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Fix `clerkFrontendApiProxy` to derive the `Clerk-Proxy-Url` header and Location rewrites from `x-forwarded-proto`/`x-forwarded-host` headers instead of the raw `request.url`. Behind a reverse proxy, `request.url` resolves to localhost, causing FAPI to receive an incorrect proxy URL. The fix uses the same forwarded-header resolution pattern as `ClerkRequest`.
5 changes: 5 additions & 0 deletions .changeset/fastify-proxy-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/fastify': minor
---

Add Frontend API proxy support to `@clerk/fastify` via the `frontendApiProxy` option on `clerkPlugin`. When enabled, requests matching the proxy path (default `/__clerk`) are forwarded to Clerk's Frontend API, allowing Clerk to work in environments where direct API access is blocked by ad blockers or firewalls. The `proxyUrl` for auth handshake is automatically derived from the request when `frontendApiProxy` is configured.
5 changes: 5 additions & 0 deletions .changeset/hono-proxy-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/hono': minor
---

Add Frontend API proxy support to `@clerk/hono` via the `frontendApiProxy` option on `clerkMiddleware`. When enabled, requests matching the proxy path (default `/__clerk`) are forwarded to Clerk's Frontend API, allowing Clerk to work in environments where direct API access is blocked by ad blockers or firewalls. The `proxyUrl` for auth handshake is automatically derived from the request when `frontendApiProxy` is configured.
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ jobs:
"nuxt",
"react-router",
"custom",
"hono",
]
test-project: ["chrome"]
include:
Expand Down
6 changes: 6 additions & 0 deletions integration/presets/envs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ const withWaitlistMode = withEmailCodes
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-waitlist-mode').sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-waitlist-mode').pk);

const withEmailCodesProxy = withEmailCodes
.clone()
.setId('withEmailCodesProxy')
.setEnvVariable('private', 'CLERK_PROXY_ENABLED', 'true');

const withSignInOrUpFlow = withEmailCodes
.clone()
.setId('withSignInOrUpFlow')
Expand Down Expand Up @@ -222,6 +227,7 @@ export const envs = {
withDynamicKeys,
withEmailCodes,
withEmailCodes_destroy_client,
withEmailCodesProxy,
withEmailCodesQuickstart,
withEmailLinks,
withKeyless,
Expand Down
1 change: 1 addition & 0 deletions integration/presets/longRunningApps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export const createLongRunningApps = () => {
* Hono apps
*/
{ id: 'hono.vite.withEmailCodes', config: hono.vite, env: envs.withEmailCodes },
{ id: 'hono.vite.withEmailCodesProxy', config: hono.vite, env: envs.withEmailCodesProxy },
] as const;

const apps = configs.map(longRunningApplication);
Expand Down
3 changes: 3 additions & 0 deletions integration/templates/hono-vite/src/server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ import ViteExpress from 'vite-express';

const app = new Hono();

const proxyEnabled = process.env.CLERK_PROXY_ENABLED === 'true';

app.use(
'*',
clerkMiddleware({
publishableKey: process.env.VITE_CLERK_PUBLISHABLE_KEY,
...(proxyEnabled ? { frontendApiProxy: { enabled: true } } : {}),
}),
);

Expand Down
84 changes: 84 additions & 0 deletions integration/tests/hono/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -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 @hono',
({ 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');
});
},
);
61 changes: 61 additions & 0 deletions packages/backend/src/__tests__/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,67 @@ describe('proxy', () => {
expect(options.headers.get('X-Forwarded-Proto')).toBe('https');
});

it('derives Clerk-Proxy-Url from forwarded headers instead of localhost', async () => {
const mockResponse = new Response(JSON.stringify({}), { status: 200 });
mockFetch.mockResolvedValue(mockResponse);

// Behind a reverse proxy, request.url is localhost but forwarded headers carry the public origin
const request = new Request('http://localhost:3000/__clerk/v1/client', {
headers: {
'X-Forwarded-Host': 'myapp.example.com',
'X-Forwarded-Proto': 'https',
},
});

await clerkFrontendApiProxy(request, {
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
secretKey: 'sk_test_xxx',
});

const [, options] = mockFetch.mock.calls[0];
expect(options.headers.get('Clerk-Proxy-Url')).toBe('https://myapp.example.com/__clerk');
});

it('falls back to request URL for Clerk-Proxy-Url when no forwarded headers', async () => {
const mockResponse = new Response(JSON.stringify({}), { status: 200 });
mockFetch.mockResolvedValue(mockResponse);

const request = new Request('https://example.com/__clerk/v1/client');

await clerkFrontendApiProxy(request, {
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
secretKey: 'sk_test_xxx',
});

const [, options] = mockFetch.mock.calls[0];
expect(options.headers.get('Clerk-Proxy-Url')).toBe('https://example.com/__clerk');
});

it('rewrites Location header using forwarded origin, not localhost', async () => {
const mockResponse = new Response(null, {
status: 302,
headers: {
Location: 'https://frontend-api.clerk.dev/v1/oauth/callback?code=123',
},
});
mockFetch.mockResolvedValue(mockResponse);

const request = new Request('http://localhost:3000/__clerk/v1/oauth/authorize', {
headers: {
'X-Forwarded-Host': 'myapp.example.com',
'X-Forwarded-Proto': 'https',
},
});

const response = await clerkFrontendApiProxy(request, {
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
secretKey: 'sk_test_xxx',
});

expect(response.status).toBe(302);
expect(response.headers.get('Location')).toBe('https://myapp.example.com/__clerk/v1/oauth/callback?code=123');
});

it('rewrites Location header for redirects pointing to FAPI', async () => {
const mockResponse = new Response(null, {
status: 302,
Expand Down
21 changes: 20 additions & 1 deletion packages/backend/src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,22 @@ function createErrorResponse(code: ProxyErrorCode, message: string, status: numb
});
}

/**
* Derives the public-facing origin from forwarded headers, falling back to the raw request URL.
* Behind a reverse proxy, request.url is typically localhost, but the Clerk-Proxy-Url header
* and Location rewrites must use the origin visible to the browser.
*/
function derivePublicOrigin(request: Request, requestUrl: URL): string {
const forwardedProto = request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim();
const forwardedHost = request.headers.get('x-forwarded-host')?.split(',')[0]?.trim();

if (forwardedProto && forwardedHost) {
return `${forwardedProto}://${forwardedHost}`;
}

return requestUrl.origin;
}

/**
* Gets the client IP address from various headers
*/
Expand Down Expand Up @@ -208,7 +224,10 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend
});

// Set required Clerk proxy headers
const proxyUrl = `${requestUrl.protocol}//${requestUrl.host}${proxyPath}`;
// Use the public origin (from forwarded headers) so the Clerk-Proxy-Url
// points to the browser-visible host, not localhost behind a reverse proxy.
const publicOrigin = derivePublicOrigin(request, requestUrl);
const proxyUrl = `${publicOrigin}${proxyPath}`;
headers.set('Clerk-Proxy-Url', proxyUrl);
headers.set('Clerk-Secret-Key', secretKey);

Expand Down
44 changes: 44 additions & 0 deletions packages/backend/src/tokens/__tests__/authenticateContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,50 @@ describe('AuthenticateContext', () => {
});
});

describe('relative proxyUrl resolution', () => {
it('resolves relative proxyUrl against forwarded origin', async () => {
const headers = new Headers({
'x-forwarded-proto': 'https',
'x-forwarded-host': 'myapp.example.com',
});
const clerkRequest = createClerkRequest(new Request('http://localhost:3000/path', { headers }));
const context = await createAuthenticateContext(clerkRequest, {
publishableKey: pkLive,
proxyUrl: '/__clerk',
});

// initPublishableKeyValues resolves it for parsePublishableKey (strips protocol)
expect(context.frontendApi).toBe('https://myapp.example.com/__clerk');
// post-Object.assign resolves this.proxyUrl
expect(context.proxyUrl).toBe('https://myapp.example.com/__clerk');
});

it('does not modify absolute proxyUrl', async () => {
const headers = new Headers({
'x-forwarded-proto': 'https',
'x-forwarded-host': 'myapp.example.com',
});
const clerkRequest = createClerkRequest(new Request('http://localhost:3000/path', { headers }));
const context = await createAuthenticateContext(clerkRequest, {
publishableKey: pkLive,
proxyUrl: 'https://custom.proxy.com/__clerk',
});

expect(context.frontendApi).toBe('https://custom.proxy.com/__clerk');
expect(context.proxyUrl).toBe('https://custom.proxy.com/__clerk');
});

it('resolves relative proxyUrl without forwarded headers using request origin', async () => {
const clerkRequest = createClerkRequest(new Request('http://localhost:3000/path'));
const context = await createAuthenticateContext(clerkRequest, {
publishableKey: pkLive,
proxyUrl: '/__clerk',
});

expect(context.proxyUrl).toBe('http://localhost:3000/__clerk');
});
});

// Added these tests to verify that the generated sha-1 is the same as the one used in cookie assignment
// Tests copied from packages/shared/src/__tests__/keys.test.ts
describe('getCookieSuffix(publishableKey, subtle)', () => {
Expand Down
15 changes: 14 additions & 1 deletion packages/backend/src/tokens/authenticateContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ class AuthenticateContext implements AuthenticateContext {

Object.assign(this, options);
this.clerkUrl = this.clerkRequest.clerkUrl;

// Resolve relative proxyUrl to absolute using the request's public origin.
if (this.proxyUrl?.startsWith('/')) {
this.proxyUrl = `${this.clerkUrl.origin}${this.proxyUrl}`;
}
}

public usesSuffixedCookies(): boolean {
Expand Down Expand Up @@ -250,6 +255,14 @@ class AuthenticateContext implements AuthenticateContext {
assertValidPublishableKey(options.publishableKey);
this.publishableKey = options.publishableKey;

// If proxyUrl is a relative path (e.g. '/__clerk'), resolve it against the
// request's public origin (derived from x-forwarded-* headers by ClerkRequest).
// This lets SDKs pass just the path instead of duplicating forwarded-header parsing.
let resolvedProxyUrl = options.proxyUrl;
if (resolvedProxyUrl?.startsWith('/')) {
resolvedProxyUrl = `${this.clerkRequest.clerkUrl.origin}${resolvedProxyUrl}`;
}

const originalPk = parsePublishableKey(this.publishableKey, {
fatal: true,
domain: options.domain,
Expand All @@ -259,7 +272,7 @@ class AuthenticateContext implements AuthenticateContext {

const pk = parsePublishableKey(this.publishableKey, {
fatal: true,
proxyUrl: options.proxyUrl,
proxyUrl: resolvedProxyUrl,
domain: options.domain,
isSatellite: options.isSatellite,
});
Expand Down
15 changes: 3 additions & 12 deletions packages/express/src/authenticateRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@ export const authenticateAndDecorateRequest = (options: ClerkMiddlewareOptions =
}
}

// Auto-derive proxyUrl from frontendApiProxy config if not explicitly set
// Pass the proxy path to authenticateRequest - the backend resolves it
// against the request's public origin (from x-forwarded-* headers).
let resolvedOptions = options;
if (frontendApiProxy && !options.proxyUrl) {
const requestUrl = new URL(request.originalUrl || request.url, `http://${request.headers.host}`);
Expand All @@ -173,17 +174,7 @@ export const authenticateAndDecorateRequest = (options: ClerkMiddlewareOptions =
? frontendApiProxy.enabled(requestUrl)
: frontendApiProxy.enabled;
if (isProxyEnabled) {
const forwardedProto = request.headers['x-forwarded-proto'];
const protoHeader = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto;
const proto = (protoHeader || '').split(',')[0].trim();
const protocol = request.secure || proto === 'https' ? 'https' : 'http';

const forwardedHost = request.headers['x-forwarded-host'];
const hostHeader = Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost;
const host = (hostHeader || '').split(',')[0].trim() || request.headers.host || 'localhost';

const derivedProxyUrl = `${protocol}://${host}${proxyPath}`;
resolvedOptions = { ...options, proxyUrl: derivedProxyUrl };
resolvedOptions = { ...options, proxyUrl: proxyPath };
}
}

Expand Down
Loading
Loading