Skip to content

Commit f0d61ea

Browse files
committed
fix(backend): derive Clerk-Proxy-Url from forwarded headers in clerkFrontendApiProxy
Behind a reverse proxy, request.url resolves to localhost, but the Clerk-Proxy-Url header and Location rewrites must use the public origin visible to the browser. Read x-forwarded-proto and x-forwarded-host headers (with fallback to request URL) to derive the correct origin. This fixes proxy support for Hono (Node adapter) and Express when running behind nginx, Cloudflare, or similar reverse proxies.
1 parent 7ea8ac2 commit f0d61ea

3 files changed

Lines changed: 86 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/backend': patch
3+
---
4+
5+
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`.

packages/backend/src/__tests__/proxy.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,67 @@ describe('proxy', () => {
380380
expect(options.headers.get('X-Forwarded-Proto')).toBe('https');
381381
});
382382

383+
it('derives Clerk-Proxy-Url from forwarded headers instead of localhost', async () => {
384+
const mockResponse = new Response(JSON.stringify({}), { status: 200 });
385+
mockFetch.mockResolvedValue(mockResponse);
386+
387+
// Behind a reverse proxy, request.url is localhost but forwarded headers carry the public origin
388+
const request = new Request('http://localhost:3000/__clerk/v1/client', {
389+
headers: {
390+
'X-Forwarded-Host': 'myapp.example.com',
391+
'X-Forwarded-Proto': 'https',
392+
},
393+
});
394+
395+
await clerkFrontendApiProxy(request, {
396+
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
397+
secretKey: 'sk_test_xxx',
398+
});
399+
400+
const [, options] = mockFetch.mock.calls[0];
401+
expect(options.headers.get('Clerk-Proxy-Url')).toBe('https://myapp.example.com/__clerk');
402+
});
403+
404+
it('falls back to request URL for Clerk-Proxy-Url when no forwarded headers', async () => {
405+
const mockResponse = new Response(JSON.stringify({}), { status: 200 });
406+
mockFetch.mockResolvedValue(mockResponse);
407+
408+
const request = new Request('https://example.com/__clerk/v1/client');
409+
410+
await clerkFrontendApiProxy(request, {
411+
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
412+
secretKey: 'sk_test_xxx',
413+
});
414+
415+
const [, options] = mockFetch.mock.calls[0];
416+
expect(options.headers.get('Clerk-Proxy-Url')).toBe('https://example.com/__clerk');
417+
});
418+
419+
it('rewrites Location header using forwarded origin, not localhost', async () => {
420+
const mockResponse = new Response(null, {
421+
status: 302,
422+
headers: {
423+
Location: 'https://frontend-api.clerk.dev/v1/oauth/callback?code=123',
424+
},
425+
});
426+
mockFetch.mockResolvedValue(mockResponse);
427+
428+
const request = new Request('http://localhost:3000/__clerk/v1/oauth/authorize', {
429+
headers: {
430+
'X-Forwarded-Host': 'myapp.example.com',
431+
'X-Forwarded-Proto': 'https',
432+
},
433+
});
434+
435+
const response = await clerkFrontendApiProxy(request, {
436+
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
437+
secretKey: 'sk_test_xxx',
438+
});
439+
440+
expect(response.status).toBe(302);
441+
expect(response.headers.get('Location')).toBe('https://myapp.example.com/__clerk/v1/oauth/callback?code=123');
442+
});
443+
383444
it('rewrites Location header for redirects pointing to FAPI', async () => {
384445
const mockResponse = new Response(null, {
385446
status: 302,

packages/backend/src/proxy.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,22 @@ function createErrorResponse(code: ProxyErrorCode, message: string, status: numb
111111
});
112112
}
113113

114+
/**
115+
* Derives the public-facing origin from forwarded headers, falling back to the raw request URL.
116+
* Behind a reverse proxy, request.url is typically localhost, but the Clerk-Proxy-Url header
117+
* and Location rewrites must use the origin visible to the browser.
118+
*/
119+
function derivePublicOrigin(request: Request, requestUrl: URL): string {
120+
const forwardedProto = request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim();
121+
const forwardedHost = request.headers.get('x-forwarded-host')?.split(',')[0]?.trim();
122+
123+
if (forwardedProto && forwardedHost) {
124+
return `${forwardedProto}://${forwardedHost}`;
125+
}
126+
127+
return requestUrl.origin;
128+
}
129+
114130
/**
115131
* Gets the client IP address from various headers
116132
*/
@@ -208,7 +224,10 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend
208224
});
209225

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

0 commit comments

Comments
 (0)