Skip to content
5 changes: 5 additions & 0 deletions .changeset/tough-ghosts-ask.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Improve the built-in Clerk Frontend API proxy, adding support for abort signals and addressing a number of small edge cases.
100 changes: 100 additions & 0 deletions packages/backend/src/__tests__/proxy.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -541,6 +541,106 @@ describe('proxy', () => {
expect(response.headers.get('Content-Type')).toBe('application/javascript');
});

it('forwards DELETE request with body', async () => {
const mockResponse = new Response(JSON.stringify({ deleted: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
mockFetch.mockResolvedValue(mockResponse);

const requestBody = JSON.stringify({ id: '123' });
const request = new Request('https://example.com/__clerk/v1/resource', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: requestBody,
});

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

expect(mockFetch).toHaveBeenCalledTimes(1);
const [, options] = mockFetch.mock.calls[0];

expect(options.method).toBe('DELETE');
expect(options.body).not.toBeNull();
expect(options.duplex).toBe('half');

expect(response.status).toBe(200);
});

it('propagates abort signal to upstream fetch', async () => {
const mockResponse = new Response(JSON.stringify({}), { status: 200 });
mockFetch.mockResolvedValue(mockResponse);

const controller = new AbortController();
const request = new Request('https://example.com/__clerk/v1/client', {
signal: controller.signal,
});

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

const [, options] = mockFetch.mock.calls[0];
expect(options.signal).toBe(request.signal);
});

it('includes Cache-Control: no-store on error responses', async () => {
const request = new Request('https://example.com/__clerk/v1/client');

// Missing publishableKey triggers an error response
const response = await clerkFrontendApiProxy(request, {
secretKey: 'sk_test_xxx',
});

expect(response.status).toBe(500);
expect(response.headers.get('Cache-Control')).toBe('no-store');
});

it('includes Cache-Control: no-store on 502 error responses', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));

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

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

expect(response.status).toBe(502);
expect(response.headers.get('Cache-Control')).toBe('no-store');
});

it('strips dynamic hop-by-hop headers listed in the Connection header from requests', async () => {
const mockResponse = new Response(JSON.stringify({}), { status: 200 });
mockFetch.mockResolvedValue(mockResponse);

const request = new Request('https://example.com/__clerk/v1/client', {
headers: {
Connection: 'keep-alive, X-Custom-Hop',
'X-Custom-Hop': 'some-value',
'User-Agent': 'Test',
},
});

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

const [, options] = mockFetch.mock.calls[0];
// Connection and X-Custom-Hop should both be stripped
expect(options.headers.has('Connection')).toBe(false);
expect(options.headers.has('X-Custom-Hop')).toBe(false);
// Non-hop-by-hop headers should be preserved
expect(options.headers.get('User-Agent')).toBe('Test');
});

it('preserves multiple Set-Cookie headers from FAPI response', async () => {
const headers = new Headers();
headers.append('Set-Cookie', '__client=abc123; Path=/; HttpOnly; Secure');
Expand Down
56 changes: 43 additions & 13 deletions packages/backend/src/proxy.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,7 +43,7 @@ export interface ProxyError {
}

// Hop-by-hop headers that should not be forwarded
const HOP_BY_HOP_HEADERS = [
const HOP_BY_HOP_HEADERS = new Set([
'connection',
'keep-alive',
'proxy-authenticate',
Expand All@@ -52,14 +52,32 @@ const HOP_BY_HOP_HEADERS = [
'trailer',
'transfer-encoding',
'upgrade',
];
]);

/**
* Parses the Connection header to extract dynamically-nominated hop-by-hop
* header names (RFC 7230 Section 6.1). These headers are specific to the
* current connection and must not be forwarded by proxies.
*/
function getDynamicHopByHopHeaders(headers: Headers): Set<string> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could probably add a unit test for this

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

covered implicitly in this test case: strips dynamic hop-by-hop headers listed in the Connection header from requests

const connectionValue = headers.get('connection');
if (!connectionValue) {
return new Set();
}
return new Set(
connectionValue
.split(',')
.map(h => h.trim().toLowerCase())
.filter(h => h.length > 0),
);
}

// Headers to strip from proxied responses. fetch() auto-decompresses
// response bodies, so Content-Encoding no longer describes the body
// and Content-Length reflects the compressed size. We request identity
// encoding upstream to avoid the double compression pass, but strip
// these defensively since servers may ignore Accept-Encoding: identity.
const RESPONSE_HEADERS_TO_STRIP = ['content-encoding', 'content-length'];
const RESPONSE_HEADERS_TO_STRIP = new Set(['content-encoding', 'content-length']);

/**
* Derives the Frontend API URL from a publishable key.
Expand DownExpand Up@@ -114,6 +132,7 @@ function createErrorResponse(code: ProxyErrorCode, message: string, status: numb
status,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
}
Expand DownExpand Up@@ -230,9 +249,12 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend
// Build headers for the proxied request
const headers = new Headers();

// Copy original headers, excluding hop-by-hop headers
// Copy original headers, excluding hop-by-hop headers and any
// dynamically-nominated hop-by-hop headers listed in the Connection header (RFC 7230 Section 6.1).
const dynamicHopByHop = getDynamicHopByHopHeaders(request.headers);
request.headers.forEach((value, key) => {
if (!HOP_BY_HOP_HEADERS.includes(key.toLowerCase())) {
const lower = key.toLowerCase();
if (!HOP_BY_HOP_HEADERS.has(lower) && !dynamicHopByHop.has(lower)) {
headers.set(key, value);
}
});
Expand DownExpand Up@@ -270,31 +292,39 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend
headers.set('X-Forwarded-For', clientIp);
}

// Determine if request has a body
const hasBody = ['POST', 'PUT', 'PATCH'].includes(request.method);
// Determine if request has a body (handles DELETE-with-body and any other method)
const hasBody = request.body !== null;

try {
// Make the proxied request
// TODO: Consider adding AbortSignal.timeout(30_000) via AbortSignal.any()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AbortSignal.timeout was added in Node 17. What other backend runtime are we waiting for support for?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good Q! Seems like it's also supported in CF workers as well, so I don't think we need to worry too much about runtime compat.

I'll handle the generic top-level timeout in a follow-up

const fetchOptions: RequestInit = {
method: request.method,
headers,
redirect: 'manual',
// @ts-expect-error - duplex is required for streaming bodies but not in all TS definitions
duplex: hasBody ? 'half' : undefined,
signal: request.signal,
};

// Only include body for methods that support it
if (hasBody && request.body) {
// Only set duplex when body is present (required for streaming bodies)
if (hasBody) {
// @ts-expect-error - duplex is required for streaming bodies, but not present on the RequestInit type from undici
fetchOptions.duplex = 'half';
fetchOptions.body = request.body;
}

const response = await fetch(targetUrl.toString(), fetchOptions);

// Build response headers, excluding hop-by-hop and encoding headers
// Build response headers, excluding hop-by-hop and encoding headers.
// Also strip dynamically-nominated hop-by-hop headers from the response Connection header.
const responseDynamicHopByHop = getDynamicHopByHopHeaders(response.headers);
const responseHeaders = new Headers();
response.headers.forEach((value, key) => {
const lower = key.toLowerCase();
if (!HOP_BY_HOP_HEADERS.includes(lower) && !RESPONSE_HEADERS_TO_STRIP.includes(lower)) {
if (
!HOP_BY_HOP_HEADERS.has(lower) &&
!RESPONSE_HEADERS_TO_STRIP.has(lower) &&
!responseDynamicHopByHop.has(lower)
) {
if (lower === 'set-cookie') {
responseHeaders.append(key, value);
} else {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(backend): harden FAPI proxy resilience and spec compliance by brkalow · Pull Request #8163 · clerk/javascript · GitHub
Skip to content
5 changes: 5 additions & 0 deletions .changeset/tough-ghosts-ask.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Improve the built-in Clerk Frontend API proxy, adding support for abort signals and addressing a number of small edge cases.
100 changes: 100 additions & 0 deletions packages/backend/src/__tests__/proxy.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -541,6 +541,106 @@ describe('proxy', () => {
expect(response.headers.get('Content-Type')).toBe('application/javascript');
});

it('forwards DELETE request with body', async () => {
const mockResponse = new Response(JSON.stringify({ deleted: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
mockFetch.mockResolvedValue(mockResponse);

const requestBody = JSON.stringify({ id: '123' });
const request = new Request('https://example.com/__clerk/v1/resource', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: requestBody,
});

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

expect(mockFetch).toHaveBeenCalledTimes(1);
const [, options] = mockFetch.mock.calls[0];

expect(options.method).toBe('DELETE');
expect(options.body).not.toBeNull();
expect(options.duplex).toBe('half');

expect(response.status).toBe(200);
});

it('propagates abort signal to upstream fetch', async () => {
const mockResponse = new Response(JSON.stringify({}), { status: 200 });
mockFetch.mockResolvedValue(mockResponse);

const controller = new AbortController();
const request = new Request('https://example.com/__clerk/v1/client', {
signal: controller.signal,
});

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

const [, options] = mockFetch.mock.calls[0];
expect(options.signal).toBe(request.signal);
});

it('includes Cache-Control: no-store on error responses', async () => {
const request = new Request('https://example.com/__clerk/v1/client');

// Missing publishableKey triggers an error response
const response = await clerkFrontendApiProxy(request, {
secretKey: 'sk_test_xxx',
});

expect(response.status).toBe(500);
expect(response.headers.get('Cache-Control')).toBe('no-store');
});

it('includes Cache-Control: no-store on 502 error responses', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));

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

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

expect(response.status).toBe(502);
expect(response.headers.get('Cache-Control')).toBe('no-store');
});

it('strips dynamic hop-by-hop headers listed in the Connection header from requests', async () => {
const mockResponse = new Response(JSON.stringify({}), { status: 200 });
mockFetch.mockResolvedValue(mockResponse);

const request = new Request('https://example.com/__clerk/v1/client', {
headers: {
Connection: 'keep-alive, X-Custom-Hop',
'X-Custom-Hop': 'some-value',
'User-Agent': 'Test',
},
});

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

const [, options] = mockFetch.mock.calls[0];
// Connection and X-Custom-Hop should both be stripped
expect(options.headers.has('Connection')).toBe(false);
expect(options.headers.has('X-Custom-Hop')).toBe(false);
// Non-hop-by-hop headers should be preserved
expect(options.headers.get('User-Agent')).toBe('Test');
});

it('preserves multiple Set-Cookie headers from FAPI response', async () => {
const headers = new Headers();
headers.append('Set-Cookie', '__client=abc123; Path=/; HttpOnly; Secure');
Expand Down
56 changes: 43 additions & 13 deletions packages/backend/src/proxy.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,7 +43,7 @@ export interface ProxyError {
}

// Hop-by-hop headers that should not be forwarded
const HOP_BY_HOP_HEADERS = [
const HOP_BY_HOP_HEADERS = new Set([
'connection',
'keep-alive',
'proxy-authenticate',
Expand All@@ -52,14 +52,32 @@ const HOP_BY_HOP_HEADERS = [
'trailer',
'transfer-encoding',
'upgrade',
];
]);

/**
* Parses the Connection header to extract dynamically-nominated hop-by-hop
* header names (RFC 7230 Section 6.1). These headers are specific to the
* current connection and must not be forwarded by proxies.
*/
function getDynamicHopByHopHeaders(headers: Headers): Set<string> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could probably add a unit test for this

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

covered implicitly in this test case: strips dynamic hop-by-hop headers listed in the Connection header from requests

const connectionValue = headers.get('connection');
if (!connectionValue) {
return new Set();
}
return new Set(
connectionValue
.split(',')
.map(h => h.trim().toLowerCase())
.filter(h => h.length > 0),
);
}

// Headers to strip from proxied responses. fetch() auto-decompresses
// response bodies, so Content-Encoding no longer describes the body
// and Content-Length reflects the compressed size. We request identity
// encoding upstream to avoid the double compression pass, but strip
// these defensively since servers may ignore Accept-Encoding: identity.
const RESPONSE_HEADERS_TO_STRIP = ['content-encoding', 'content-length'];
const RESPONSE_HEADERS_TO_STRIP = new Set(['content-encoding', 'content-length']);

/**
* Derives the Frontend API URL from a publishable key.
Expand DownExpand Up@@ -114,6 +132,7 @@ function createErrorResponse(code: ProxyErrorCode, message: string, status: numb
status,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
}
Expand DownExpand Up@@ -230,9 +249,12 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend
// Build headers for the proxied request
const headers = new Headers();

// Copy original headers, excluding hop-by-hop headers
// Copy original headers, excluding hop-by-hop headers and any
// dynamically-nominated hop-by-hop headers listed in the Connection header (RFC 7230 Section 6.1).
const dynamicHopByHop = getDynamicHopByHopHeaders(request.headers);
request.headers.forEach((value, key) => {
if (!HOP_BY_HOP_HEADERS.includes(key.toLowerCase())) {
const lower = key.toLowerCase();
if (!HOP_BY_HOP_HEADERS.has(lower) && !dynamicHopByHop.has(lower)) {
headers.set(key, value);
}
});
Expand DownExpand Up@@ -270,31 +292,39 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend
headers.set('X-Forwarded-For', clientIp);
}

// Determine if request has a body
const hasBody = ['POST', 'PUT', 'PATCH'].includes(request.method);
// Determine if request has a body (handles DELETE-with-body and any other method)
const hasBody = request.body !== null;

try {
// Make the proxied request
// TODO: Consider adding AbortSignal.timeout(30_000) via AbortSignal.any()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AbortSignal.timeout was added in Node 17. What other backend runtime are we waiting for support for?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good Q! Seems like it's also supported in CF workers as well, so I don't think we need to worry too much about runtime compat.

I'll handle the generic top-level timeout in a follow-up

const fetchOptions: RequestInit = {
method: request.method,
headers,
redirect: 'manual',
// @ts-expect-error - duplex is required for streaming bodies but not in all TS definitions
duplex: hasBody ? 'half' : undefined,
signal: request.signal,
};

// Only include body for methods that support it
if (hasBody && request.body) {
// Only set duplex when body is present (required for streaming bodies)
if (hasBody) {
// @ts-expect-error - duplex is required for streaming bodies, but not present on the RequestInit type from undici
fetchOptions.duplex = 'half';
fetchOptions.body = request.body;
}

const response = await fetch(targetUrl.toString(), fetchOptions);

// Build response headers, excluding hop-by-hop and encoding headers
// Build response headers, excluding hop-by-hop and encoding headers.
// Also strip dynamically-nominated hop-by-hop headers from the response Connection header.
const responseDynamicHopByHop = getDynamicHopByHopHeaders(response.headers);
const responseHeaders = new Headers();
response.headers.forEach((value, key) => {
const lower = key.toLowerCase();
if (!HOP_BY_HOP_HEADERS.includes(lower) && !RESPONSE_HEADERS_TO_STRIP.includes(lower)) {
if (
!HOP_BY_HOP_HEADERS.has(lower) &&
!RESPONSE_HEADERS_TO_STRIP.has(lower) &&
!responseDynamicHopByHop.has(lower)
) {
if (lower === 'set-cookie') {
responseHeaders.append(key, value);
} else {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(backend): harden FAPI proxy resilience and spec compliance by brkalow · Pull Request #8163 · clerk/javascript · GitHub
Skip to content
5 changes: 5 additions & 0 deletions .changeset/tough-ghosts-ask.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Improve the built-in Clerk Frontend API proxy, adding support for abort signals and addressing a number of small edge cases.
100 changes: 100 additions & 0 deletions packages/backend/src/__tests__/proxy.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -541,6 +541,106 @@ describe('proxy', () => {
expect(response.headers.get('Content-Type')).toBe('application/javascript');
});

it('forwards DELETE request with body', async () => {
const mockResponse = new Response(JSON.stringify({ deleted: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
mockFetch.mockResolvedValue(mockResponse);

const requestBody = JSON.stringify({ id: '123' });
const request = new Request('https://example.com/__clerk/v1/resource', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: requestBody,
});

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

expect(mockFetch).toHaveBeenCalledTimes(1);
const [, options] = mockFetch.mock.calls[0];

expect(options.method).toBe('DELETE');
expect(options.body).not.toBeNull();
expect(options.duplex).toBe('half');

expect(response.status).toBe(200);
});

it('propagates abort signal to upstream fetch', async () => {
const mockResponse = new Response(JSON.stringify({}), { status: 200 });
mockFetch.mockResolvedValue(mockResponse);

const controller = new AbortController();
const request = new Request('https://example.com/__clerk/v1/client', {
signal: controller.signal,
});

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

const [, options] = mockFetch.mock.calls[0];
expect(options.signal).toBe(request.signal);
});

it('includes Cache-Control: no-store on error responses', async () => {
const request = new Request('https://example.com/__clerk/v1/client');

// Missing publishableKey triggers an error response
const response = await clerkFrontendApiProxy(request, {
secretKey: 'sk_test_xxx',
});

expect(response.status).toBe(500);
expect(response.headers.get('Cache-Control')).toBe('no-store');
});

it('includes Cache-Control: no-store on 502 error responses', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));

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

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

expect(response.status).toBe(502);
expect(response.headers.get('Cache-Control')).toBe('no-store');
});

it('strips dynamic hop-by-hop headers listed in the Connection header from requests', async () => {
const mockResponse = new Response(JSON.stringify({}), { status: 200 });
mockFetch.mockResolvedValue(mockResponse);

const request = new Request('https://example.com/__clerk/v1/client', {
headers: {
Connection: 'keep-alive, X-Custom-Hop',
'X-Custom-Hop': 'some-value',
'User-Agent': 'Test',
},
});

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

const [, options] = mockFetch.mock.calls[0];
// Connection and X-Custom-Hop should both be stripped
expect(options.headers.has('Connection')).toBe(false);
expect(options.headers.has('X-Custom-Hop')).toBe(false);
// Non-hop-by-hop headers should be preserved
expect(options.headers.get('User-Agent')).toBe('Test');
});

it('preserves multiple Set-Cookie headers from FAPI response', async () => {
const headers = new Headers();
headers.append('Set-Cookie', '__client=abc123; Path=/; HttpOnly; Secure');
Expand Down
56 changes: 43 additions & 13 deletions packages/backend/src/proxy.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,7 +43,7 @@ export interface ProxyError {
}

// Hop-by-hop headers that should not be forwarded
const HOP_BY_HOP_HEADERS = [
const HOP_BY_HOP_HEADERS = new Set([
'connection',
'keep-alive',
'proxy-authenticate',
Expand All@@ -52,14 +52,32 @@ const HOP_BY_HOP_HEADERS = [
'trailer',
'transfer-encoding',
'upgrade',
];
]);

/**
* Parses the Connection header to extract dynamically-nominated hop-by-hop
* header names (RFC 7230 Section 6.1). These headers are specific to the
* current connection and must not be forwarded by proxies.
*/
function getDynamicHopByHopHeaders(headers: Headers): Set<string> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could probably add a unit test for this

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

covered implicitly in this test case: strips dynamic hop-by-hop headers listed in the Connection header from requests

const connectionValue = headers.get('connection');
if (!connectionValue) {
return new Set();
}
return new Set(
connectionValue
.split(',')
.map(h => h.trim().toLowerCase())
.filter(h => h.length > 0),
);
}

// Headers to strip from proxied responses. fetch() auto-decompresses
// response bodies, so Content-Encoding no longer describes the body
// and Content-Length reflects the compressed size. We request identity
// encoding upstream to avoid the double compression pass, but strip
// these defensively since servers may ignore Accept-Encoding: identity.
const RESPONSE_HEADERS_TO_STRIP = ['content-encoding', 'content-length'];
const RESPONSE_HEADERS_TO_STRIP = new Set(['content-encoding', 'content-length']);

/**
* Derives the Frontend API URL from a publishable key.
Expand DownExpand Up@@ -114,6 +132,7 @@ function createErrorResponse(code: ProxyErrorCode, message: string, status: numb
status,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
}
Expand DownExpand Up@@ -230,9 +249,12 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend
// Build headers for the proxied request
const headers = new Headers();

// Copy original headers, excluding hop-by-hop headers
// Copy original headers, excluding hop-by-hop headers and any
// dynamically-nominated hop-by-hop headers listed in the Connection header (RFC 7230 Section 6.1).
const dynamicHopByHop = getDynamicHopByHopHeaders(request.headers);
request.headers.forEach((value, key) => {
if (!HOP_BY_HOP_HEADERS.includes(key.toLowerCase())) {
const lower = key.toLowerCase();
if (!HOP_BY_HOP_HEADERS.has(lower) && !dynamicHopByHop.has(lower)) {
headers.set(key, value);
}
});
Expand DownExpand Up@@ -270,31 +292,39 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend
headers.set('X-Forwarded-For', clientIp);
}

// Determine if request has a body
const hasBody = ['POST', 'PUT', 'PATCH'].includes(request.method);
// Determine if request has a body (handles DELETE-with-body and any other method)
const hasBody = request.body !== null;

try {
// Make the proxied request
// TODO: Consider adding AbortSignal.timeout(30_000) via AbortSignal.any()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AbortSignal.timeout was added in Node 17. What other backend runtime are we waiting for support for?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good Q! Seems like it's also supported in CF workers as well, so I don't think we need to worry too much about runtime compat.

I'll handle the generic top-level timeout in a follow-up

const fetchOptions: RequestInit = {
method: request.method,
headers,
redirect: 'manual',
// @ts-expect-error - duplex is required for streaming bodies but not in all TS definitions
duplex: hasBody ? 'half' : undefined,
signal: request.signal,
};

// Only include body for methods that support it
if (hasBody && request.body) {
// Only set duplex when body is present (required for streaming bodies)
if (hasBody) {
// @ts-expect-error - duplex is required for streaming bodies, but not present on the RequestInit type from undici
fetchOptions.duplex = 'half';
fetchOptions.body = request.body;
}

const response = await fetch(targetUrl.toString(), fetchOptions);

// Build response headers, excluding hop-by-hop and encoding headers
// Build response headers, excluding hop-by-hop and encoding headers.
// Also strip dynamically-nominated hop-by-hop headers from the response Connection header.
const responseDynamicHopByHop = getDynamicHopByHopHeaders(response.headers);
const responseHeaders = new Headers();
response.headers.forEach((value, key) => {
const lower = key.toLowerCase();
if (!HOP_BY_HOP_HEADERS.includes(lower) && !RESPONSE_HEADERS_TO_STRIP.includes(lower)) {
if (
!HOP_BY_HOP_HEADERS.has(lower) &&
!RESPONSE_HEADERS_TO_STRIP.has(lower) &&
!responseDynamicHopByHop.has(lower)
) {
if (lower === 'set-cookie') {
responseHeaders.append(key, value);
} else {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(backend): harden FAPI proxy resilience and spec compliance by brkalow · Pull Request #8163 · clerk/javascript · GitHub
Skip to content
5 changes: 5 additions & 0 deletions .changeset/tough-ghosts-ask.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Improve the built-in Clerk Frontend API proxy, adding support for abort signals and addressing a number of small edge cases.
100 changes: 100 additions & 0 deletions packages/backend/src/__tests__/proxy.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -541,6 +541,106 @@ describe('proxy', () => {
expect(response.headers.get('Content-Type')).toBe('application/javascript');
});

it('forwards DELETE request with body', async () => {
const mockResponse = new Response(JSON.stringify({ deleted: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
mockFetch.mockResolvedValue(mockResponse);

const requestBody = JSON.stringify({ id: '123' });
const request = new Request('https://example.com/__clerk/v1/resource', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: requestBody,
});

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

expect(mockFetch).toHaveBeenCalledTimes(1);
const [, options] = mockFetch.mock.calls[0];

expect(options.method).toBe('DELETE');
expect(options.body).not.toBeNull();
expect(options.duplex).toBe('half');

expect(response.status).toBe(200);
});

it('propagates abort signal to upstream fetch', async () => {
const mockResponse = new Response(JSON.stringify({}), { status: 200 });
mockFetch.mockResolvedValue(mockResponse);

const controller = new AbortController();
const request = new Request('https://example.com/__clerk/v1/client', {
signal: controller.signal,
});

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

const [, options] = mockFetch.mock.calls[0];
expect(options.signal).toBe(request.signal);
});

it('includes Cache-Control: no-store on error responses', async () => {
const request = new Request('https://example.com/__clerk/v1/client');

// Missing publishableKey triggers an error response
const response = await clerkFrontendApiProxy(request, {
secretKey: 'sk_test_xxx',
});

expect(response.status).toBe(500);
expect(response.headers.get('Cache-Control')).toBe('no-store');
});

it('includes Cache-Control: no-store on 502 error responses', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));

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

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

expect(response.status).toBe(502);
expect(response.headers.get('Cache-Control')).toBe('no-store');
});

it('strips dynamic hop-by-hop headers listed in the Connection header from requests', async () => {
const mockResponse = new Response(JSON.stringify({}), { status: 200 });
mockFetch.mockResolvedValue(mockResponse);

const request = new Request('https://example.com/__clerk/v1/client', {
headers: {
Connection: 'keep-alive, X-Custom-Hop',
'X-Custom-Hop': 'some-value',
'User-Agent': 'Test',
},
});

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

const [, options] = mockFetch.mock.calls[0];
// Connection and X-Custom-Hop should both be stripped
expect(options.headers.has('Connection')).toBe(false);
expect(options.headers.has('X-Custom-Hop')).toBe(false);
// Non-hop-by-hop headers should be preserved
expect(options.headers.get('User-Agent')).toBe('Test');
});

it('preserves multiple Set-Cookie headers from FAPI response', async () => {
const headers = new Headers();
headers.append('Set-Cookie', '__client=abc123; Path=/; HttpOnly; Secure');
Expand Down
56 changes: 43 additions & 13 deletions packages/backend/src/proxy.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,7 +43,7 @@ export interface ProxyError {
}

// Hop-by-hop headers that should not be forwarded
const HOP_BY_HOP_HEADERS = [
const HOP_BY_HOP_HEADERS = new Set([
'connection',
'keep-alive',
'proxy-authenticate',
Expand All@@ -52,14 +52,32 @@ const HOP_BY_HOP_HEADERS = [
'trailer',
'transfer-encoding',
'upgrade',
];
]);

/**
* Parses the Connection header to extract dynamically-nominated hop-by-hop
* header names (RFC 7230 Section 6.1). These headers are specific to the
* current connection and must not be forwarded by proxies.
*/
function getDynamicHopByHopHeaders(headers: Headers): Set<string> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could probably add a unit test for this

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

covered implicitly in this test case: strips dynamic hop-by-hop headers listed in the Connection header from requests

const connectionValue = headers.get('connection');
if (!connectionValue) {
return new Set();
}
return new Set(
connectionValue
.split(',')
.map(h => h.trim().toLowerCase())
.filter(h => h.length > 0),
);
}

// Headers to strip from proxied responses. fetch() auto-decompresses
// response bodies, so Content-Encoding no longer describes the body
// and Content-Length reflects the compressed size. We request identity
// encoding upstream to avoid the double compression pass, but strip
// these defensively since servers may ignore Accept-Encoding: identity.
const RESPONSE_HEADERS_TO_STRIP = ['content-encoding', 'content-length'];
const RESPONSE_HEADERS_TO_STRIP = new Set(['content-encoding', 'content-length']);

/**
* Derives the Frontend API URL from a publishable key.
Expand DownExpand Up@@ -114,6 +132,7 @@ function createErrorResponse(code: ProxyErrorCode, message: string, status: numb
status,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
}
Expand DownExpand Up@@ -230,9 +249,12 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend
// Build headers for the proxied request
const headers = new Headers();

// Copy original headers, excluding hop-by-hop headers
// Copy original headers, excluding hop-by-hop headers and any
// dynamically-nominated hop-by-hop headers listed in the Connection header (RFC 7230 Section 6.1).
const dynamicHopByHop = getDynamicHopByHopHeaders(request.headers);
request.headers.forEach((value, key) => {
if (!HOP_BY_HOP_HEADERS.includes(key.toLowerCase())) {
const lower = key.toLowerCase();
if (!HOP_BY_HOP_HEADERS.has(lower) && !dynamicHopByHop.has(lower)) {
headers.set(key, value);
}
});
Expand DownExpand Up@@ -270,31 +292,39 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend
headers.set('X-Forwarded-For', clientIp);
}

// Determine if request has a body
const hasBody = ['POST', 'PUT', 'PATCH'].includes(request.method);
// Determine if request has a body (handles DELETE-with-body and any other method)
const hasBody = request.body !== null;

try {
// Make the proxied request
// TODO: Consider adding AbortSignal.timeout(30_000) via AbortSignal.any()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AbortSignal.timeout was added in Node 17. What other backend runtime are we waiting for support for?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good Q! Seems like it's also supported in CF workers as well, so I don't think we need to worry too much about runtime compat.

I'll handle the generic top-level timeout in a follow-up

const fetchOptions: RequestInit = {
method: request.method,
headers,
redirect: 'manual',
// @ts-expect-error - duplex is required for streaming bodies but not in all TS definitions
duplex: hasBody ? 'half' : undefined,
signal: request.signal,
};

// Only include body for methods that support it
if (hasBody && request.body) {
// Only set duplex when body is present (required for streaming bodies)
if (hasBody) {
// @ts-expect-error - duplex is required for streaming bodies, but not present on the RequestInit type from undici
fetchOptions.duplex = 'half';
fetchOptions.body = request.body;
}

const response = await fetch(targetUrl.toString(), fetchOptions);

// Build response headers, excluding hop-by-hop and encoding headers
// Build response headers, excluding hop-by-hop and encoding headers.
// Also strip dynamically-nominated hop-by-hop headers from the response Connection header.
const responseDynamicHopByHop = getDynamicHopByHopHeaders(response.headers);
const responseHeaders = new Headers();
response.headers.forEach((value, key) => {
const lower = key.toLowerCase();
if (!HOP_BY_HOP_HEADERS.includes(lower) && !RESPONSE_HEADERS_TO_STRIP.includes(lower)) {
if (
!HOP_BY_HOP_HEADERS.has(lower) &&
!RESPONSE_HEADERS_TO_STRIP.has(lower) &&
!responseDynamicHopByHop.has(lower)
) {
if (lower === 'set-cookie') {
responseHeaders.append(key, value);
} else {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(backend): harden FAPI proxy resilience and spec compliance by brkalow · Pull Request #8163 · clerk/javascript · GitHub
Skip to content
5 changes: 5 additions & 0 deletions .changeset/tough-ghosts-ask.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Improve the built-in Clerk Frontend API proxy, adding support for abort signals and addressing a number of small edge cases.
100 changes: 100 additions & 0 deletions packages/backend/src/__tests__/proxy.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -541,6 +541,106 @@ describe('proxy', () => {
expect(response.headers.get('Content-Type')).toBe('application/javascript');
});

it('forwards DELETE request with body', async () => {
const mockResponse = new Response(JSON.stringify({ deleted: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
mockFetch.mockResolvedValue(mockResponse);

const requestBody = JSON.stringify({ id: '123' });
const request = new Request('https://example.com/__clerk/v1/resource', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: requestBody,
});

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

expect(mockFetch).toHaveBeenCalledTimes(1);
const [, options] = mockFetch.mock.calls[0];

expect(options.method).toBe('DELETE');
expect(options.body).not.toBeNull();
expect(options.duplex).toBe('half');

expect(response.status).toBe(200);
});

it('propagates abort signal to upstream fetch', async () => {
const mockResponse = new Response(JSON.stringify({}), { status: 200 });
mockFetch.mockResolvedValue(mockResponse);

const controller = new AbortController();
const request = new Request('https://example.com/__clerk/v1/client', {
signal: controller.signal,
});

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

const [, options] = mockFetch.mock.calls[0];
expect(options.signal).toBe(request.signal);
});

it('includes Cache-Control: no-store on error responses', async () => {
const request = new Request('https://example.com/__clerk/v1/client');

// Missing publishableKey triggers an error response
const response = await clerkFrontendApiProxy(request, {
secretKey: 'sk_test_xxx',
});

expect(response.status).toBe(500);
expect(response.headers.get('Cache-Control')).toBe('no-store');
});

it('includes Cache-Control: no-store on 502 error responses', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));

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

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

expect(response.status).toBe(502);
expect(response.headers.get('Cache-Control')).toBe('no-store');
});

it('strips dynamic hop-by-hop headers listed in the Connection header from requests', async () => {
const mockResponse = new Response(JSON.stringify({}), { status: 200 });
mockFetch.mockResolvedValue(mockResponse);

const request = new Request('https://example.com/__clerk/v1/client', {
headers: {
Connection: 'keep-alive, X-Custom-Hop',
'X-Custom-Hop': 'some-value',
'User-Agent': 'Test',
},
});

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

const [, options] = mockFetch.mock.calls[0];
// Connection and X-Custom-Hop should both be stripped
expect(options.headers.has('Connection')).toBe(false);
expect(options.headers.has('X-Custom-Hop')).toBe(false);
// Non-hop-by-hop headers should be preserved
expect(options.headers.get('User-Agent')).toBe('Test');
});

it('preserves multiple Set-Cookie headers from FAPI response', async () => {
const headers = new Headers();
headers.append('Set-Cookie', '__client=abc123; Path=/; HttpOnly; Secure');
Expand Down
56 changes: 43 additions & 13 deletions packages/backend/src/proxy.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,7 +43,7 @@ export interface ProxyError {
}

// Hop-by-hop headers that should not be forwarded
const HOP_BY_HOP_HEADERS = [
const HOP_BY_HOP_HEADERS = new Set([
'connection',
'keep-alive',
'proxy-authenticate',
Expand All@@ -52,14 +52,32 @@ const HOP_BY_HOP_HEADERS = [
'trailer',
'transfer-encoding',
'upgrade',
];
]);

/**
* Parses the Connection header to extract dynamically-nominated hop-by-hop
* header names (RFC 7230 Section 6.1). These headers are specific to the
* current connection and must not be forwarded by proxies.
*/
function getDynamicHopByHopHeaders(headers: Headers): Set<string> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could probably add a unit test for this

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

covered implicitly in this test case: strips dynamic hop-by-hop headers listed in the Connection header from requests

const connectionValue = headers.get('connection');
if (!connectionValue) {
return new Set();
}
return new Set(
connectionValue
.split(',')
.map(h => h.trim().toLowerCase())
.filter(h => h.length > 0),
);
}

// Headers to strip from proxied responses. fetch() auto-decompresses
// response bodies, so Content-Encoding no longer describes the body
// and Content-Length reflects the compressed size. We request identity
// encoding upstream to avoid the double compression pass, but strip
// these defensively since servers may ignore Accept-Encoding: identity.
const RESPONSE_HEADERS_TO_STRIP = ['content-encoding', 'content-length'];
const RESPONSE_HEADERS_TO_STRIP = new Set(['content-encoding', 'content-length']);

/**
* Derives the Frontend API URL from a publishable key.
Expand DownExpand Up@@ -114,6 +132,7 @@ function createErrorResponse(code: ProxyErrorCode, message: string, status: numb
status,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
}
Expand DownExpand Up@@ -230,9 +249,12 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend
// Build headers for the proxied request
const headers = new Headers();

// Copy original headers, excluding hop-by-hop headers
// Copy original headers, excluding hop-by-hop headers and any
// dynamically-nominated hop-by-hop headers listed in the Connection header (RFC 7230 Section 6.1).
const dynamicHopByHop = getDynamicHopByHopHeaders(request.headers);
request.headers.forEach((value, key) => {
if (!HOP_BY_HOP_HEADERS.includes(key.toLowerCase())) {
const lower = key.toLowerCase();
if (!HOP_BY_HOP_HEADERS.has(lower) && !dynamicHopByHop.has(lower)) {
headers.set(key, value);
}
});
Expand DownExpand Up@@ -270,31 +292,39 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend
headers.set('X-Forwarded-For', clientIp);
}

// Determine if request has a body
const hasBody = ['POST', 'PUT', 'PATCH'].includes(request.method);
// Determine if request has a body (handles DELETE-with-body and any other method)
const hasBody = request.body !== null;

try {
// Make the proxied request
// TODO: Consider adding AbortSignal.timeout(30_000) via AbortSignal.any()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AbortSignal.timeout was added in Node 17. What other backend runtime are we waiting for support for?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good Q! Seems like it's also supported in CF workers as well, so I don't think we need to worry too much about runtime compat.

I'll handle the generic top-level timeout in a follow-up

const fetchOptions: RequestInit = {
method: request.method,
headers,
redirect: 'manual',
// @ts-expect-error - duplex is required for streaming bodies but not in all TS definitions
duplex: hasBody ? 'half' : undefined,
signal: request.signal,
};

// Only include body for methods that support it
if (hasBody && request.body) {
// Only set duplex when body is present (required for streaming bodies)
if (hasBody) {
// @ts-expect-error - duplex is required for streaming bodies, but not present on the RequestInit type from undici
fetchOptions.duplex = 'half';
fetchOptions.body = request.body;
}

const response = await fetch(targetUrl.toString(), fetchOptions);

// Build response headers, excluding hop-by-hop and encoding headers
// Build response headers, excluding hop-by-hop and encoding headers.
// Also strip dynamically-nominated hop-by-hop headers from the response Connection header.
const responseDynamicHopByHop = getDynamicHopByHopHeaders(response.headers);
const responseHeaders = new Headers();
response.headers.forEach((value, key) => {
const lower = key.toLowerCase();
if (!HOP_BY_HOP_HEADERS.includes(lower) && !RESPONSE_HEADERS_TO_STRIP.includes(lower)) {
if (
!HOP_BY_HOP_HEADERS.has(lower) &&
!RESPONSE_HEADERS_TO_STRIP.has(lower) &&
!responseDynamicHopByHop.has(lower)
) {
if (lower === 'set-cookie') {
responseHeaders.append(key, value);
} else {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(backend): harden FAPI proxy resilience and spec compliance by brkalow · Pull Request #8163 · clerk/javascript · GitHub
Skip to content
5 changes: 5 additions & 0 deletions .changeset/tough-ghosts-ask.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Improve the built-in Clerk Frontend API proxy, adding support for abort signals and addressing a number of small edge cases.
100 changes: 100 additions & 0 deletions packages/backend/src/__tests__/proxy.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -541,6 +541,106 @@ describe('proxy', () => {
expect(response.headers.get('Content-Type')).toBe('application/javascript');
});

it('forwards DELETE request with body', async () => {
const mockResponse = new Response(JSON.stringify({ deleted: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
mockFetch.mockResolvedValue(mockResponse);

const requestBody = JSON.stringify({ id: '123' });
const request = new Request('https://example.com/__clerk/v1/resource', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: requestBody,
});

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

expect(mockFetch).toHaveBeenCalledTimes(1);
const [, options] = mockFetch.mock.calls[0];

expect(options.method).toBe('DELETE');
expect(options.body).not.toBeNull();
expect(options.duplex).toBe('half');

expect(response.status).toBe(200);
});

it('propagates abort signal to upstream fetch', async () => {
const mockResponse = new Response(JSON.stringify({}), { status: 200 });
mockFetch.mockResolvedValue(mockResponse);

const controller = new AbortController();
const request = new Request('https://example.com/__clerk/v1/client', {
signal: controller.signal,
});

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

const [, options] = mockFetch.mock.calls[0];
expect(options.signal).toBe(request.signal);
});

it('includes Cache-Control: no-store on error responses', async () => {
const request = new Request('https://example.com/__clerk/v1/client');

// Missing publishableKey triggers an error response
const response = await clerkFrontendApiProxy(request, {
secretKey: 'sk_test_xxx',
});

expect(response.status).toBe(500);
expect(response.headers.get('Cache-Control')).toBe('no-store');
});

it('includes Cache-Control: no-store on 502 error responses', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));

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

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

expect(response.status).toBe(502);
expect(response.headers.get('Cache-Control')).toBe('no-store');
});

it('strips dynamic hop-by-hop headers listed in the Connection header from requests', async () => {
const mockResponse = new Response(JSON.stringify({}), { status: 200 });
mockFetch.mockResolvedValue(mockResponse);

const request = new Request('https://example.com/__clerk/v1/client', {
headers: {
Connection: 'keep-alive, X-Custom-Hop',
'X-Custom-Hop': 'some-value',
'User-Agent': 'Test',
},
});

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

const [, options] = mockFetch.mock.calls[0];
// Connection and X-Custom-Hop should both be stripped
expect(options.headers.has('Connection')).toBe(false);
expect(options.headers.has('X-Custom-Hop')).toBe(false);
// Non-hop-by-hop headers should be preserved
expect(options.headers.get('User-Agent')).toBe('Test');
});

it('preserves multiple Set-Cookie headers from FAPI response', async () => {
const headers = new Headers();
headers.append('Set-Cookie', '__client=abc123; Path=/; HttpOnly; Secure');
Expand Down
56 changes: 43 additions & 13 deletions packages/backend/src/proxy.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,7 +43,7 @@ export interface ProxyError {
}

// Hop-by-hop headers that should not be forwarded
const HOP_BY_HOP_HEADERS = [
const HOP_BY_HOP_HEADERS = new Set([
'connection',
'keep-alive',
'proxy-authenticate',
Expand All@@ -52,14 +52,32 @@ const HOP_BY_HOP_HEADERS = [
'trailer',
'transfer-encoding',
'upgrade',
];
]);

/**
* Parses the Connection header to extract dynamically-nominated hop-by-hop
* header names (RFC 7230 Section 6.1). These headers are specific to the
* current connection and must not be forwarded by proxies.
*/
function getDynamicHopByHopHeaders(headers: Headers): Set<string> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could probably add a unit test for this

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

covered implicitly in this test case: strips dynamic hop-by-hop headers listed in the Connection header from requests

const connectionValue = headers.get('connection');
if (!connectionValue) {
return new Set();
}
return new Set(
connectionValue
.split(',')
.map(h => h.trim().toLowerCase())
.filter(h => h.length > 0),
);
}

// Headers to strip from proxied responses. fetch() auto-decompresses
// response bodies, so Content-Encoding no longer describes the body
// and Content-Length reflects the compressed size. We request identity
// encoding upstream to avoid the double compression pass, but strip
// these defensively since servers may ignore Accept-Encoding: identity.
const RESPONSE_HEADERS_TO_STRIP = ['content-encoding', 'content-length'];
const RESPONSE_HEADERS_TO_STRIP = new Set(['content-encoding', 'content-length']);

/**
* Derives the Frontend API URL from a publishable key.
Expand DownExpand Up@@ -114,6 +132,7 @@ function createErrorResponse(code: ProxyErrorCode, message: string, status: numb
status,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
}
Expand DownExpand Up@@ -230,9 +249,12 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend
// Build headers for the proxied request
const headers = new Headers();

// Copy original headers, excluding hop-by-hop headers
// Copy original headers, excluding hop-by-hop headers and any
// dynamically-nominated hop-by-hop headers listed in the Connection header (RFC 7230 Section 6.1).
const dynamicHopByHop = getDynamicHopByHopHeaders(request.headers);
request.headers.forEach((value, key) => {
if (!HOP_BY_HOP_HEADERS.includes(key.toLowerCase())) {
const lower = key.toLowerCase();
if (!HOP_BY_HOP_HEADERS.has(lower) && !dynamicHopByHop.has(lower)) {
headers.set(key, value);
}
});
Expand DownExpand Up@@ -270,31 +292,39 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend
headers.set('X-Forwarded-For', clientIp);
}

// Determine if request has a body
const hasBody = ['POST', 'PUT', 'PATCH'].includes(request.method);
// Determine if request has a body (handles DELETE-with-body and any other method)
const hasBody = request.body !== null;

try {
// Make the proxied request
// TODO: Consider adding AbortSignal.timeout(30_000) via AbortSignal.any()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AbortSignal.timeout was added in Node 17. What other backend runtime are we waiting for support for?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good Q! Seems like it's also supported in CF workers as well, so I don't think we need to worry too much about runtime compat.

I'll handle the generic top-level timeout in a follow-up

const fetchOptions: RequestInit = {
method: request.method,
headers,
redirect: 'manual',
// @ts-expect-error - duplex is required for streaming bodies but not in all TS definitions
duplex: hasBody ? 'half' : undefined,
signal: request.signal,
};

// Only include body for methods that support it
if (hasBody && request.body) {
// Only set duplex when body is present (required for streaming bodies)
if (hasBody) {
// @ts-expect-error - duplex is required for streaming bodies, but not present on the RequestInit type from undici
fetchOptions.duplex = 'half';
fetchOptions.body = request.body;
}

const response = await fetch(targetUrl.toString(), fetchOptions);

// Build response headers, excluding hop-by-hop and encoding headers
// Build response headers, excluding hop-by-hop and encoding headers.
// Also strip dynamically-nominated hop-by-hop headers from the response Connection header.
const responseDynamicHopByHop = getDynamicHopByHopHeaders(response.headers);
const responseHeaders = new Headers();
response.headers.forEach((value, key) => {
const lower = key.toLowerCase();
if (!HOP_BY_HOP_HEADERS.includes(lower) && !RESPONSE_HEADERS_TO_STRIP.includes(lower)) {
if (
!HOP_BY_HOP_HEADERS.has(lower) &&
!RESPONSE_HEADERS_TO_STRIP.has(lower) &&
!responseDynamicHopByHop.has(lower)
) {
if (lower === 'set-cookie') {
responseHeaders.append(key, value);
} else {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(backend): harden FAPI proxy resilience and spec compliance by brkalow · Pull Request #8163 · clerk/javascript · GitHub
Skip to content
5 changes: 5 additions & 0 deletions .changeset/tough-ghosts-ask.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Improve the built-in Clerk Frontend API proxy, adding support for abort signals and addressing a number of small edge cases.
100 changes: 100 additions & 0 deletions packages/backend/src/__tests__/proxy.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -541,6 +541,106 @@ describe('proxy', () => {
expect(response.headers.get('Content-Type')).toBe('application/javascript');
});

it('forwards DELETE request with body', async () => {
const mockResponse = new Response(JSON.stringify({ deleted: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
mockFetch.mockResolvedValue(mockResponse);

const requestBody = JSON.stringify({ id: '123' });
const request = new Request('https://example.com/__clerk/v1/resource', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: requestBody,
});

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

expect(mockFetch).toHaveBeenCalledTimes(1);
const [, options] = mockFetch.mock.calls[0];

expect(options.method).toBe('DELETE');
expect(options.body).not.toBeNull();
expect(options.duplex).toBe('half');

expect(response.status).toBe(200);
});

it('propagates abort signal to upstream fetch', async () => {
const mockResponse = new Response(JSON.stringify({}), { status: 200 });
mockFetch.mockResolvedValue(mockResponse);

const controller = new AbortController();
const request = new Request('https://example.com/__clerk/v1/client', {
signal: controller.signal,
});

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

const [, options] = mockFetch.mock.calls[0];
expect(options.signal).toBe(request.signal);
});

it('includes Cache-Control: no-store on error responses', async () => {
const request = new Request('https://example.com/__clerk/v1/client');

// Missing publishableKey triggers an error response
const response = await clerkFrontendApiProxy(request, {
secretKey: 'sk_test_xxx',
});

expect(response.status).toBe(500);
expect(response.headers.get('Cache-Control')).toBe('no-store');
});

it('includes Cache-Control: no-store on 502 error responses', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));

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

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

expect(response.status).toBe(502);
expect(response.headers.get('Cache-Control')).toBe('no-store');
});

it('strips dynamic hop-by-hop headers listed in the Connection header from requests', async () => {
const mockResponse = new Response(JSON.stringify({}), { status: 200 });
mockFetch.mockResolvedValue(mockResponse);

const request = new Request('https://example.com/__clerk/v1/client', {
headers: {
Connection: 'keep-alive, X-Custom-Hop',
'X-Custom-Hop': 'some-value',
'User-Agent': 'Test',
},
});

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

const [, options] = mockFetch.mock.calls[0];
// Connection and X-Custom-Hop should both be stripped
expect(options.headers.has('Connection')).toBe(false);
expect(options.headers.has('X-Custom-Hop')).toBe(false);
// Non-hop-by-hop headers should be preserved
expect(options.headers.get('User-Agent')).toBe('Test');
});

it('preserves multiple Set-Cookie headers from FAPI response', async () => {
const headers = new Headers();
headers.append('Set-Cookie', '__client=abc123; Path=/; HttpOnly; Secure');
Expand Down
56 changes: 43 additions & 13 deletions packages/backend/src/proxy.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,7 +43,7 @@ export interface ProxyError {
}

// Hop-by-hop headers that should not be forwarded
const HOP_BY_HOP_HEADERS = [
const HOP_BY_HOP_HEADERS = new Set([
'connection',
'keep-alive',
'proxy-authenticate',
Expand All@@ -52,14 +52,32 @@ const HOP_BY_HOP_HEADERS = [
'trailer',
'transfer-encoding',
'upgrade',
];
]);

/**
* Parses the Connection header to extract dynamically-nominated hop-by-hop
* header names (RFC 7230 Section 6.1). These headers are specific to the
* current connection and must not be forwarded by proxies.
*/
function getDynamicHopByHopHeaders(headers: Headers): Set<string> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could probably add a unit test for this

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

covered implicitly in this test case: strips dynamic hop-by-hop headers listed in the Connection header from requests

const connectionValue = headers.get('connection');
if (!connectionValue) {
return new Set();
}
return new Set(
connectionValue
.split(',')
.map(h => h.trim().toLowerCase())
.filter(h => h.length > 0),
);
}

// Headers to strip from proxied responses. fetch() auto-decompresses
// response bodies, so Content-Encoding no longer describes the body
// and Content-Length reflects the compressed size. We request identity
// encoding upstream to avoid the double compression pass, but strip
// these defensively since servers may ignore Accept-Encoding: identity.
const RESPONSE_HEADERS_TO_STRIP = ['content-encoding', 'content-length'];
const RESPONSE_HEADERS_TO_STRIP = new Set(['content-encoding', 'content-length']);

/**
* Derives the Frontend API URL from a publishable key.
Expand DownExpand Up@@ -114,6 +132,7 @@ function createErrorResponse(code: ProxyErrorCode, message: string, status: numb
status,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
}
Expand DownExpand Up@@ -230,9 +249,12 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend
// Build headers for the proxied request
const headers = new Headers();

// Copy original headers, excluding hop-by-hop headers
// Copy original headers, excluding hop-by-hop headers and any
// dynamically-nominated hop-by-hop headers listed in the Connection header (RFC 7230 Section 6.1).
const dynamicHopByHop = getDynamicHopByHopHeaders(request.headers);
request.headers.forEach((value, key) => {
if (!HOP_BY_HOP_HEADERS.includes(key.toLowerCase())) {
const lower = key.toLowerCase();
if (!HOP_BY_HOP_HEADERS.has(lower) && !dynamicHopByHop.has(lower)) {
headers.set(key, value);
}
});
Expand DownExpand Up@@ -270,31 +292,39 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend
headers.set('X-Forwarded-For', clientIp);
}

// Determine if request has a body
const hasBody = ['POST', 'PUT', 'PATCH'].includes(request.method);
// Determine if request has a body (handles DELETE-with-body and any other method)
const hasBody = request.body !== null;

try {
// Make the proxied request
// TODO: Consider adding AbortSignal.timeout(30_000) via AbortSignal.any()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AbortSignal.timeout was added in Node 17. What other backend runtime are we waiting for support for?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good Q! Seems like it's also supported in CF workers as well, so I don't think we need to worry too much about runtime compat.

I'll handle the generic top-level timeout in a follow-up

const fetchOptions: RequestInit = {
method: request.method,
headers,
redirect: 'manual',
// @ts-expect-error - duplex is required for streaming bodies but not in all TS definitions
duplex: hasBody ? 'half' : undefined,
signal: request.signal,
};

// Only include body for methods that support it
if (hasBody && request.body) {
// Only set duplex when body is present (required for streaming bodies)
if (hasBody) {
// @ts-expect-error - duplex is required for streaming bodies, but not present on the RequestInit type from undici
fetchOptions.duplex = 'half';
fetchOptions.body = request.body;
}

const response = await fetch(targetUrl.toString(), fetchOptions);

// Build response headers, excluding hop-by-hop and encoding headers
// Build response headers, excluding hop-by-hop and encoding headers.
// Also strip dynamically-nominated hop-by-hop headers from the response Connection header.
const responseDynamicHopByHop = getDynamicHopByHopHeaders(response.headers);
const responseHeaders = new Headers();
response.headers.forEach((value, key) => {
const lower = key.toLowerCase();
if (!HOP_BY_HOP_HEADERS.includes(lower) && !RESPONSE_HEADERS_TO_STRIP.includes(lower)) {
if (
!HOP_BY_HOP_HEADERS.has(lower) &&
!RESPONSE_HEADERS_TO_STRIP.has(lower) &&
!responseDynamicHopByHop.has(lower)
) {
if (lower === 'set-cookie') {
responseHeaders.append(key, value);
} else {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(backend): harden FAPI proxy resilience and spec compliance by brkalow · Pull Request #8163 · clerk/javascript · GitHub
Skip to content
5 changes: 5 additions & 0 deletions .changeset/tough-ghosts-ask.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Improve the built-in Clerk Frontend API proxy, adding support for abort signals and addressing a number of small edge cases.
100 changes: 100 additions & 0 deletions packages/backend/src/__tests__/proxy.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -541,6 +541,106 @@ describe('proxy', () => {
expect(response.headers.get('Content-Type')).toBe('application/javascript');
});

it('forwards DELETE request with body', async () => {
const mockResponse = new Response(JSON.stringify({ deleted: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
mockFetch.mockResolvedValue(mockResponse);

const requestBody = JSON.stringify({ id: '123' });
const request = new Request('https://example.com/__clerk/v1/resource', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: requestBody,
});

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

expect(mockFetch).toHaveBeenCalledTimes(1);
const [, options] = mockFetch.mock.calls[0];

expect(options.method).toBe('DELETE');
expect(options.body).not.toBeNull();
expect(options.duplex).toBe('half');

expect(response.status).toBe(200);
});

it('propagates abort signal to upstream fetch', async () => {
const mockResponse = new Response(JSON.stringify({}), { status: 200 });
mockFetch.mockResolvedValue(mockResponse);

const controller = new AbortController();
const request = new Request('https://example.com/__clerk/v1/client', {
signal: controller.signal,
});

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

const [, options] = mockFetch.mock.calls[0];
expect(options.signal).toBe(request.signal);
});

it('includes Cache-Control: no-store on error responses', async () => {
const request = new Request('https://example.com/__clerk/v1/client');

// Missing publishableKey triggers an error response
const response = await clerkFrontendApiProxy(request, {
secretKey: 'sk_test_xxx',
});

expect(response.status).toBe(500);
expect(response.headers.get('Cache-Control')).toBe('no-store');
});

it('includes Cache-Control: no-store on 502 error responses', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));

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

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

expect(response.status).toBe(502);
expect(response.headers.get('Cache-Control')).toBe('no-store');
});

it('strips dynamic hop-by-hop headers listed in the Connection header from requests', async () => {
const mockResponse = new Response(JSON.stringify({}), { status: 200 });
mockFetch.mockResolvedValue(mockResponse);

const request = new Request('https://example.com/__clerk/v1/client', {
headers: {
Connection: 'keep-alive, X-Custom-Hop',
'X-Custom-Hop': 'some-value',
'User-Agent': 'Test',
},
});

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

const [, options] = mockFetch.mock.calls[0];
// Connection and X-Custom-Hop should both be stripped
expect(options.headers.has('Connection')).toBe(false);
expect(options.headers.has('X-Custom-Hop')).toBe(false);
// Non-hop-by-hop headers should be preserved
expect(options.headers.get('User-Agent')).toBe('Test');
});

it('preserves multiple Set-Cookie headers from FAPI response', async () => {
const headers = new Headers();
headers.append('Set-Cookie', '__client=abc123; Path=/; HttpOnly; Secure');
Expand Down
56 changes: 43 additions & 13 deletions packages/backend/src/proxy.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,7 +43,7 @@ export interface ProxyError {
}

// Hop-by-hop headers that should not be forwarded
const HOP_BY_HOP_HEADERS = [
const HOP_BY_HOP_HEADERS = new Set([
'connection',
'keep-alive',
'proxy-authenticate',
Expand All@@ -52,14 +52,32 @@ const HOP_BY_HOP_HEADERS = [
'trailer',
'transfer-encoding',
'upgrade',
];
]);

/**
* Parses the Connection header to extract dynamically-nominated hop-by-hop
* header names (RFC 7230 Section 6.1). These headers are specific to the
* current connection and must not be forwarded by proxies.
*/
function getDynamicHopByHopHeaders(headers: Headers): Set<string> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could probably add a unit test for this

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

covered implicitly in this test case: strips dynamic hop-by-hop headers listed in the Connection header from requests

const connectionValue = headers.get('connection');
if (!connectionValue) {
return new Set();
}
return new Set(
connectionValue
.split(',')
.map(h => h.trim().toLowerCase())
.filter(h => h.length > 0),
);
}

// Headers to strip from proxied responses. fetch() auto-decompresses
// response bodies, so Content-Encoding no longer describes the body
// and Content-Length reflects the compressed size. We request identity
// encoding upstream to avoid the double compression pass, but strip
// these defensively since servers may ignore Accept-Encoding: identity.
const RESPONSE_HEADERS_TO_STRIP = ['content-encoding', 'content-length'];
const RESPONSE_HEADERS_TO_STRIP = new Set(['content-encoding', 'content-length']);

/**
* Derives the Frontend API URL from a publishable key.
Expand DownExpand Up@@ -114,6 +132,7 @@ function createErrorResponse(code: ProxyErrorCode, message: string, status: numb
status,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
}
Expand DownExpand Up@@ -230,9 +249,12 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend
// Build headers for the proxied request
const headers = new Headers();

// Copy original headers, excluding hop-by-hop headers
// Copy original headers, excluding hop-by-hop headers and any
// dynamically-nominated hop-by-hop headers listed in the Connection header (RFC 7230 Section 6.1).
const dynamicHopByHop = getDynamicHopByHopHeaders(request.headers);
request.headers.forEach((value, key) => {
if (!HOP_BY_HOP_HEADERS.includes(key.toLowerCase())) {
const lower = key.toLowerCase();
if (!HOP_BY_HOP_HEADERS.has(lower) && !dynamicHopByHop.has(lower)) {
headers.set(key, value);
}
});
Expand DownExpand Up@@ -270,31 +292,39 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend
headers.set('X-Forwarded-For', clientIp);
}

// Determine if request has a body
const hasBody = ['POST', 'PUT', 'PATCH'].includes(request.method);
// Determine if request has a body (handles DELETE-with-body and any other method)
const hasBody = request.body !== null;

try {
// Make the proxied request
// TODO: Consider adding AbortSignal.timeout(30_000) via AbortSignal.any()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AbortSignal.timeout was added in Node 17. What other backend runtime are we waiting for support for?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good Q! Seems like it's also supported in CF workers as well, so I don't think we need to worry too much about runtime compat.

I'll handle the generic top-level timeout in a follow-up

const fetchOptions: RequestInit = {
method: request.method,
headers,
redirect: 'manual',
// @ts-expect-error - duplex is required for streaming bodies but not in all TS definitions
duplex: hasBody ? 'half' : undefined,
signal: request.signal,
};

// Only include body for methods that support it
if (hasBody && request.body) {
// Only set duplex when body is present (required for streaming bodies)
if (hasBody) {
// @ts-expect-error - duplex is required for streaming bodies, but not present on the RequestInit type from undici
fetchOptions.duplex = 'half';
fetchOptions.body = request.body;
}

const response = await fetch(targetUrl.toString(), fetchOptions);

// Build response headers, excluding hop-by-hop and encoding headers
// Build response headers, excluding hop-by-hop and encoding headers.
// Also strip dynamically-nominated hop-by-hop headers from the response Connection header.
const responseDynamicHopByHop = getDynamicHopByHopHeaders(response.headers);
const responseHeaders = new Headers();
response.headers.forEach((value, key) => {
const lower = key.toLowerCase();
if (!HOP_BY_HOP_HEADERS.includes(lower) && !RESPONSE_HEADERS_TO_STRIP.includes(lower)) {
if (
!HOP_BY_HOP_HEADERS.has(lower) &&
!RESPONSE_HEADERS_TO_STRIP.has(lower) &&
!responseDynamicHopByHop.has(lower)
) {
if (lower === 'set-cookie') {
responseHeaders.append(key, value);
} else {
Expand Down
Loading