Root cause
SSRF & network boundaries introduced
feat(backend): Add Frontend API proxy helpers (#7602)
How AI contributed: Direct introductionPublished Mar 27, 2026 in
clerk/javascript
The AI-coauthored frontend API proxy helper resolved its upstream target against the Clerk Frontend API base URL, so a path beginning with a double slash, such as /__clerk//evil.com/steal, became a protocol-relative reference to an attacker-controlled host. The proxy forwarded the request together with the application's Clerk secret key header, exposing that key to the attacker's host. The behavior is reachable only when the opt-in clerkFrontendApiProxy feature is enabled, and the fix in @clerk/backend 3.2.3 builds the target by concatenation and verifies the host before forwarding.
packages/backend/src/proxy.ts derived the upstream URL with new URL(targetPath, fapiBaseUrl), and the URL parser treats a leading pair of slashes as an authority, so the request path replaced the host instead of staying under the Frontend API base. The fix concatenates the trusted base URL with the path and confirms that the parsed host still matches the Frontend API host before sending the request that carries the Clerk secret key.
Root cause
feat(backend): Add Frontend API proxy helpers (#7602)
Fix
fix(backend): preserve Set-Cookie headers through proxy (#8162)
AI-assisted fix: Claude Code · Bryce Kalow
Case facts
@@ -0,0 +1,112 @@+import {+ clerkFrontendApiProxy as backendProxy,+ DEFAULT_PROXY_PATH,+ type FrontendApiProxyOptions,+} from '@clerk/backend/proxy';++import { PUBLISHABLE_KEY, SECRET_KEY } from './constants';++export { DEFAULT_PROXY_PATH, type FrontendApiProxyOptions } from '@clerk/backend/proxy';++/**+ * Options for the Next.js Frontend API proxy+ */+export interface NextFrontendApiProxyOptions extends Omit<FrontendApiProxyOptions, 'proxyPath'> {+ /**+ * The path prefix for proxy requests. For App Router route handlers,+ * this is typically derived from the route path.+ */+ proxyPath?: string;+}++/**+ * Proxies a request to Clerk's Frontend API in Next.js App Router.+ *+ * This function handles forwarding requests from your application to Clerk's+ * Frontend API, enabling scenarios where direct communication with Clerk's API+ * is blocked or needs to go through your application server.+ *+ * @param request - The incoming Next.js request+ * @param options - Proxy configuration options+ * @returns A Response from Clerk's Frontend API+ *+ * @example+ * ```typescript+ * // app/api/__clerk/[[...path]]/route.ts+ * import { clerkFrontendApiProxy } from '@clerk/nextjs/server';+ *+ * export async function GET(request: Request) {+ * return clerkFrontendApiProxy(request);+ * }+ *+ * export async function POST(request: Request) {+ * return clerkFrontendApiProxy(request);+ * }+ * ```+ */+export async function clerkFrontendApiProxy(+ request: Request,+ options?: NextFrontendApiProxyOptions,+): Promise<Response> {+ return backendProxy(request, {+ proxyPath: options?.proxyPath || DEFAULT_PROXY_PATH,+ publishableKey: options?.publishableKey || PUBLISHABLE_KEY,+ secretKey: options?.secretKey || SECRET_KEY,+ });+}++/**+ * Route handler type for Next.js App Router+ */+type RouteHandler = (request: Request) => Promise<Response>;++/**+ * Collection of route handlers for all HTTP methods+ */+export interface FrontendApiProxyHandlers {+ GET: RouteHandler;+ POST: RouteHandler;+ PUT: RouteHandler;+ DELETE: RouteHandler;+ PATCH: RouteHandler;+}++/**+ * Creates route handlers for proxying Clerk Frontend API requests.+ *+ * This function returns an object with handlers for GET, POST, PUT, DELETE, and PATCH+ * methods that can be directly exported from a Next.js App Router route file.+ *+ * @param options - Proxy configuration options+ * @returns An object with route handlers for all HTTP methods+ *+ * @example+ * ```typescript+ * // app/api/__clerk/[[...path]]/route.ts+ * import { createFrontendApiProxyHandlers } from '@clerk/nextjs/server';+ *+ * export const { GET, POST, PUT, DELETE, PATCH } = createFrontendApiProxyHandlers();+ * ```@@ -20,6 +20,7 @@ import { makeAuthObjectSerializable, TokenType, } from '@clerk/backend/internal';+import { clerkFrontendApiProxy, DEFAULT_PROXY_PATH, matchProxyPath } from '@clerk/backend/proxy'; import { parsePublishableKey } from '@clerk/shared/keys'; import { notFound as nextjsNotFound } from 'next/navigation'; import type { NextMiddleware, NextRequest } from 'next/server';@@ -50,7 +51,12 @@ import { } from './nextErrors'; import type { AuthProtect } from './protect'; import { createProtect } from './protect';-import type { NextMiddlewareEvtParam, NextMiddlewareRequestParam, NextMiddlewareReturn } from './types';+import type {+ FrontendApiProxyOptions,+ NextMiddlewareEvtParam,+ NextMiddlewareRequestParam,+ NextMiddlewareReturn,+} from './types'; import { assertKey, decorateRequest,@@ -88,6 +94,12 @@ export interface ClerkMiddlewareOptions extends AuthenticateAnyRequestOptions { * When set, automatically injects a Content-Security-Policy header(s) compatible with Clerk. */ contentSecurityPolicy?: ContentSecurityPolicyOptions;++ /**+ * When set, enables the middleware to proxy Frontend API requests to Clerk.+ * This is useful when direct communication with Clerk's API is blocked.+ */+ frontendApiProxy?: FrontendApiProxyOptions; } type ClerkMiddlewareOptionsCallback = (req: NextRequest) => ClerkMiddlewareOptions | Promise<ClerkMiddlewareOptions>;@@ -144,6 +156,25 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl const secretKey = assertKey(resolvedParams.secretKey || SECRET_KEY || keyless?.secretKey, () => errorThrower.throwMissingSecretKeyError(), );++ // Handle Frontend API proxy requests early, before authentication+ const frontendApiProxyConfig = resolvedParams.frontendApiProxy;+ if (frontendApiProxyConfig) {+ const { enabled, path: proxyPath = DEFAULT_PROXY_PATH } = frontendApiProxyConfig;++ // Resolve enabled - either boolean or function+ const requestUrl = new URL(request.url);+ const isEnabled = typeof enabled === 'function' ? enabled(requestUrl) : enabled;++ if (isEnabled && matchProxyPath(request, { proxyPath })) {+ return clerkFrontendApiProxy(request, {+ proxyPath,+ publishableKey,+ secretKey,+ });+ }+ }+ const signInUrl = resolvedParams.signInUrl || SIGN_IN_URL; const signUpUrl = resolvedParams.signUpUrl || SIGN_UP_URL; @@ -368,9 +399,22 @@ export const createAuthenticateRequestOptions = ( clerkRequest: ClerkRequest, options: ClerkMiddlewareOptions, ): Parameters<AuthenticateRequest>[1] => {+ // Auto-derive proxyUrl from frontendApiProxy config if not explicitly set+ let resolvedOptions = options;+ if (options.frontendApiProxy && !options.proxyUrl) {+ const { enabled, path: proxyPath = DEFAULT_PROXY_PATH } = options.frontendApiProxy;+ const requestUrl = new URL(clerkRequest.url);+ const isEnabled = typeof enabled === 'function' ? enabled(requestUrl) : enabled;++ if (isEnabled) {+ const derivedProxyUrl = `${requestUrl.origin}${proxyPath}`;+ resolvedOptions = { ...options, proxyUrl: derivedProxyUrl };+ }+ }+ return {- ...options,- ...handleMultiDomainAndProxy(clerkRequest, options),+ ...resolvedOptions,+ ...handleMultiDomainAndProxy(clerkRequest, resolvedOptions), // TODO: Leaving the acceptsToken as 'any' opens up the possibility of // an economic attack. We should revisit this and only verify a token // when auth() or auth.protect() is invoked.@@ -1,12 +1,32 @@ import type { createClerkClient } from '@clerk/backend'; import type { AuthenticateRequestOptions, SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend/internal';+import type { ShouldProxyFn } from '@clerk/shared/proxy'; import type { PendingSessionOptions } from '@clerk/shared/types'; import type { Request as ExpressRequest } from 'express'; export type ExpressRequestWithAuth = ExpressRequest & { auth: (options?: PendingSessionOptions) => SignedInAuthObject | SignedOutAuthObject; }; +/**+ * Options for configuring Frontend API proxy in clerkMiddleware+ */+export interface FrontendApiProxyOptions {+ /**+ * Enable proxy handling. Can be:+ * - `true` - enable for all domains+ * - `false` - disable for all domains+ * - A function: (url: URL) => boolean - enable based on the request URL+ */+ enabled: boolean | ShouldProxyFn;+ /**+ * The path prefix for proxy requests.+ *+ * @default '/__clerk'+ */+ path?: string;+}+ export type ClerkMiddlewareOptions = AuthenticateRequestOptions & { debug?: boolean; clerkClient?: ClerkClient;@@ -18,6 +38,24 @@ export type ClerkMiddlewareOptions = AuthenticateRequestOptions & { * @default true */ enableHandshake?: boolean;+ /**+ * Configure Frontend API proxy handling. When set, requests to the proxy path+ * will skip authentication, and the proxyUrl will be automatically derived+ * for handshake redirects.+ *+ * @example+ * // Enable with defaults (path: '/__clerk')+ * clerkMiddleware({ frontendApiProxy: { enabled: true } })+ *+ * @example+ * // Custom path+ * clerkMiddleware({ frontendApiProxy: { enabled: true, path: '/my-proxy' } })+ *+ * @example+ * // Disable proxy handling+ * clerkMiddleware({ frontendApiProxy: { enabled: false } })+ */+ frontendApiProxy?: FrontendApiProxyOptions; }; type ClerkClient = ReturnType<typeof createClerkClient>;@@ -214,12 +214,19 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend ); } - // Derive the FAPI URL and construct the target URL+ // Derive the FAPI URL and construct the target URL.+ // Use string concatenation instead of `new URL(path, base)` to avoid+ // protocol-relative resolution (e.g., "//evil.com" resolving to a different host). const fapiBaseUrl = fapiUrlFromPublishableKey(publishableKey);+ const fapiHost = new URL(fapiBaseUrl).host; const targetPath = requestUrl.pathname.slice(proxyPath.length) || '/';- const targetUrl = new URL(targetPath, fapiBaseUrl);+ const targetUrl = new URL(`${fapiBaseUrl}${targetPath}`); targetUrl.search = requestUrl.search; + if (targetUrl.host !== fapiHost) {+ return createErrorResponse('proxy_request_failed', 'Resolved target does not match the expected host', 400);+ }+ // Build headers for the proxied request const headers = new Headers(); @@ -239,7 +246,6 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend headers.set('Clerk-Secret-Key', secretKey); // Set the host header to the FAPI host- const fapiHost = new URL(fapiBaseUrl).host; headers.set('Host', fapiHost); // Request uncompressed responses to avoid a double compression pass.@@ -288,7 +294,11 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend response.headers.forEach((value, key) => { const lower = key.toLowerCase(); if (!HOP_BY_HOP_HEADERS.includes(lower) && !RESPONSE_HEADERS_TO_STRIP.includes(lower)) {- responseHeaders.set(key, value);+ if (lower === 'set-cookie') {+ responseHeaders.append(key, value);+ } else {+ responseHeaders.set(key, value);+ } } }); @@ -148,6 +148,22 @@ describe('proxy', () => { expect(body.errors[0].code).toBe('proxy_path_mismatch'); }); + it('does not follow protocol-relative paths', async () => {+ const mockResponse = new Response('{}', { status: 200 });+ mockFetch.mockResolvedValueOnce(mockResponse);++ const request = new Request('https://example.com/__clerk//evil.com/steal');++ await clerkFrontendApiProxy(request, {+ publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',+ secretKey: 'sk_test_xxx',+ });++ // String concatenation keeps the host as FAPI, not evil.com+ const fetchedUrl = new URL(mockFetch.mock.calls[0][0] as string);+ expect(fetchedUrl.host).toBe('frontend-api.clerk.dev');+ });+ it('forwards GET request to FAPI with correct headers', async () => { const mockResponse = new Response(JSON.stringify({ client: {} }), { status: 200,@@ -525,6 +541,33 @@ describe('proxy', () => { expect(response.headers.get('Content-Type')).toBe('application/javascript'); }); + it('preserves multiple Set-Cookie headers from FAPI response', async () => {+ const headers = new Headers();+ headers.append('Set-Cookie', '__client=abc123; Path=/; HttpOnly; Secure');+ headers.append('Set-Cookie', '__client_uat=1234567890; Path=/; Secure');+ headers.append('Set-Cookie', '__session=xyz789; Path=/; HttpOnly; Secure');+ headers.append('Content-Type', 'application/json');++ const mockResponse = new Response(JSON.stringify({ client: {} }), {+ status: 200,+ headers,+ });+ mockFetch.mockResolvedValue(mockResponse);++ const request = new Request('https://example.com/__clerk/v1/client');++ const response = await clerkFrontendApiProxy(request, {+ publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',+ secretKey: 'sk_test_xxx',+ });++ const setCookies = response.headers.getSetCookie();+ expect(setCookies).toHaveLength(3);+ expect(setCookies).toContain('__client=abc123; Path=/; HttpOnly; Secure');+ expect(setCookies).toContain('__client_uat=1234567890; Path=/; Secure');+ expect(setCookies).toContain('__session=xyz789; Path=/; HttpOnly; Secure');+ });+ it('preserves relative Location headers', async () => { const mockResponse = new Response(null, { status: 302,@@ -0,0 +1,5 @@+---+'@clerk/backend': patch+---++Fix an issue where multiple `set-cookie` headers were being dropped by the frontend API proxy.AI-assisted change a0c6960889c750233e0b8a1dec86b54ef9a05de7661287e5c8b6250366758ef4 · Fix d82f76046b7df365551b02507485aff1825864d8ec8011468cdcdfa38ab21bc5