From 70420c0cf185141d5d2b6f061560812b8bc22f03 Mon Sep 17 00:00:00 2001 From: brkalow Date: Tue, 10 Mar 2026 23:06:38 -0500 Subject: [PATCH 01/31] feat(shared,backend,clerk-js,nextjs): Auto-proxy for .vercel.app subdomains Add automatic FAPI proxying detection for Vercel preview deployments across all SDKs. When an app is deployed to a .vercel.app subdomain without explicit proxy/domain configuration, the SDK automatically routes FAPI requests through the app's own domain via /__clerk proxy path. - Add isVercelPreviewDeploy() helper in @clerk/shared/proxy - Auto-detect in clerk-js proxyUrl getter for client-side SDK initialization - Auto-detect in @clerk/backend authenticateContext for server-side auth - Enable proxy interception in Next.js middleware for /__clerk/* requests on .vercel.app - Add comprehensive tests for all three layers (shared, backend, clerk-js, nextjs) Co-Authored-By: Claude Haiku 4.5 --- .../__tests__/authenticateContext.test.ts | 40 ++++++++++++ .../backend/src/tokens/authenticateContext.ts | 9 +++ .../clerk-js/src/core/__tests__/clerk.test.ts | 65 +++++++++++++++++++ packages/clerk-js/src/core/clerk.ts | 11 +++- .../server/__tests__/clerkMiddleware.test.ts | 15 +++++ packages/nextjs/src/server/clerkMiddleware.ts | 18 ++++- packages/shared/src/__tests__/proxy.spec.ts | 30 ++++++++- packages/shared/src/proxy.ts | 4 ++ 8 files changed, 186 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts index b640a07ea79..576f4289ad5 100644 --- a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts +++ b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts @@ -258,6 +258,46 @@ describe('AuthenticateContext', () => { }); }); + describe('auto-proxy for .vercel.app', () => { + it('auto-derives proxyUrl for .vercel.app hostnames', async () => { + const clerkRequest = createClerkRequest(new Request('https://myapp-abc123.vercel.app/dashboard')); + const context = await createAuthenticateContext(clerkRequest, { + publishableKey: pkTest, + }); + + expect(context.proxyUrl).toBe('https://myapp-abc123.vercel.app/__clerk'); + }); + + it('does NOT auto-derive proxyUrl for non-.vercel.app domains', async () => { + const clerkRequest = createClerkRequest(new Request('https://myapp.com/dashboard')); + const context = await createAuthenticateContext(clerkRequest, { + publishableKey: pkTest, + }); + + expect(context.proxyUrl).toBeUndefined(); + }); + + it('explicit proxyUrl takes precedence over auto-detection', async () => { + const clerkRequest = createClerkRequest(new Request('https://myapp-abc123.vercel.app/dashboard')); + const context = await createAuthenticateContext(clerkRequest, { + publishableKey: pkTest, + proxyUrl: 'https://custom-proxy.example.com/__clerk', + }); + + expect(context.proxyUrl).toBe('https://custom-proxy.example.com/__clerk'); + }); + + it('explicit domain skips auto-detection', async () => { + const clerkRequest = createClerkRequest(new Request('https://myapp-abc123.vercel.app/dashboard')); + const context = await createAuthenticateContext(clerkRequest, { + publishableKey: pkTest, + domain: 'clerk.myapp.com', + }); + + expect(context.proxyUrl).toBeUndefined(); + }); + }); + // Added these tests to verify that the generated sha-1 is the same as the one used in cookie assignment // Tests copied from packages/shared/src/__tests__/keys.test.ts describe('getCookieSuffix(publishableKey, subtle)', () => { diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index 55c0ed6ad21..3b0a1c5ee68 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -1,4 +1,5 @@ import { buildAccountsBaseUrl } from '@clerk/shared/buildAccountsBaseUrl'; +import { isVercelPreviewDeploy } from '@clerk/shared/proxy'; import type { Jwt } from '@clerk/shared/types'; import { isCurrentDevAccountPortalOrigin, isLegacyDevAccountPortalOrigin } from '@clerk/shared/url'; @@ -69,6 +70,14 @@ class AuthenticateContext implements AuthenticateContext { private clerkRequest: ClerkRequest, options: AuthenticateRequestOptions, ) { + // Auto-detect proxy for Vercel preview deployments + if (!options.proxyUrl && !options.domain) { + const hostname = clerkRequest.clerkUrl.hostname; + if (isVercelPreviewDeploy(hostname)) { + options = { ...options, proxyUrl: `${clerkRequest.clerkUrl.origin}/__clerk` }; + } + } + if (options.acceptsToken === TokenType.M2MToken || options.acceptsToken === TokenType.ApiKey) { // For non-session tokens, we only want to set the header values. this.initHeaderValues(); diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 4d9fb15bc5b..41f7b6224d2 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -2491,6 +2491,71 @@ describe('Clerk singleton', () => { expect(sut.getFapiClient().buildUrl({ path: '/me' }).href).toContain('https://proxy.com/api/__clerk/v1/me'); }); }); + + describe('auto-detection for .vercel.app', () => { + const originalLocation = window.location; + + afterEach(() => { + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + }); + }); + + test('auto-derives proxyUrl when hostname is .vercel.app', () => { + Object.defineProperty(window, 'location', { + value: { + ...originalLocation, + hostname: 'myapp-abc123.vercel.app', + origin: 'https://myapp-abc123.vercel.app', + href: 'https://myapp-abc123.vercel.app/dashboard', + }, + writable: true, + }); + + const sut = new Clerk(developmentPublishableKey); + expect(sut.proxyUrl).toBe('https://myapp-abc123.vercel.app/__clerk'); + }); + + test('does NOT auto-derive proxyUrl for non-.vercel.app domains', () => { + const sut = new Clerk(developmentPublishableKey); + expect(sut.proxyUrl).toBe(''); + }); + + test('explicit proxyUrl takes precedence over auto-detection', () => { + Object.defineProperty(window, 'location', { + value: { + ...originalLocation, + hostname: 'myapp-abc123.vercel.app', + origin: 'https://myapp-abc123.vercel.app', + href: 'https://myapp-abc123.vercel.app/dashboard', + }, + writable: true, + }); + + const sut = new Clerk(developmentPublishableKey, { + proxyUrl: 'https://custom-proxy.example.com/__clerk', + }); + expect(sut.proxyUrl).toBe('https://custom-proxy.example.com/__clerk'); + }); + + test('explicit domain skips auto-detection', () => { + Object.defineProperty(window, 'location', { + value: { + ...originalLocation, + hostname: 'myapp-abc123.vercel.app', + origin: 'https://myapp-abc123.vercel.app', + href: 'https://myapp-abc123.vercel.app/dashboard', + }, + writable: true, + }); + + const sut = new Clerk(developmentPublishableKey, { + domain: 'clerk.myapp.com', + }); + expect(sut.proxyUrl).toBe(''); + }); + }); }); describe('buildUrlWithAuth', () => { diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 1a362b2edac..68941e0707b 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -38,7 +38,7 @@ import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate'; import { parsePublishableKey } from '@clerk/shared/keys'; import { logger } from '@clerk/shared/logger'; import { CLERK_NETLIFY_CACHE_BUST_PARAM } from '@clerk/shared/netlifyCacheHandler'; -import { isHttpOrHttps, isValidProxyUrl, proxyUrlToAbsoluteURL } from '@clerk/shared/proxy'; +import { isHttpOrHttps, isValidProxyUrl, isVercelPreviewDeploy, proxyUrlToAbsoluteURL } from '@clerk/shared/proxy'; import { eventPrebuiltComponentMounted, eventPrebuiltComponentOpened, @@ -351,7 +351,14 @@ export class Clerk implements ClerkInterface { if (!isValidProxyUrl(_unfilteredProxy)) { errorThrower.throwInvalidProxyUrl({ url: _unfilteredProxy }); } - return proxyUrlToAbsoluteURL(_unfilteredProxy); + const resolved = proxyUrlToAbsoluteURL(_unfilteredProxy); + if (resolved) { + return resolved; + } + // Auto-detect for Vercel preview deployments when no explicit proxy or domain is configured + if (!this.#domain && isVercelPreviewDeploy(window.location.hostname)) { + return `${window.location.origin}/__clerk`; + } } return ''; } diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts index 101589ae596..ba4ce124360 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -1191,6 +1191,21 @@ describe('frontendApiProxy multi-domain support', () => { }); }); +describe('auto-proxy for .vercel.app', () => { + it('auto-intercepts /__clerk/* requests on .vercel.app hostnames', async () => { + const req = new NextRequest(new URL('/__clerk/v1/client', 'https://myapp-abc123.vercel.app').toString(), { + method: 'GET', + headers: new Headers(), + }); + + const resp = await clerkMiddleware()(req, {} as NextFetchEvent); + + // Proxy should intercept the request — authenticateRequest should NOT be called + expect((await clerkClient()).authenticateRequest).not.toBeCalled(); + expect(resp?.status).toBeDefined(); + }); +}); + describe('contentSecurityPolicy option', () => { it('forwards CSP headers as request headers when strict mode is enabled', async () => { const resp = await clerkMiddleware({ diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index c50949ef99f..bdb1e592b72 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -23,6 +23,7 @@ import { import { clerkFrontendApiProxy, DEFAULT_PROXY_PATH, matchProxyPath } from '@clerk/backend/proxy'; import { parsePublishableKey } from '@clerk/shared/keys'; import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler'; +import { isVercelPreviewDeploy } from '@clerk/shared/proxy'; import { notFound as nextjsNotFound } from 'next/navigation'; import type { NextMiddleware, NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; @@ -33,7 +34,7 @@ import { isRedirect, serverRedirectWithAuth, setHeader } from '../utils'; import { withLogger } from '../utils/debugLogger'; import { canUseKeyless } from '../utils/feature-flags'; import { clerkClient } from './clerkClient'; -import { PUBLISHABLE_KEY, SECRET_KEY, SIGN_IN_URL, SIGN_UP_URL } from './constants'; +import { DOMAIN, PROXY_URL, PUBLISHABLE_KEY, SECRET_KEY, SIGN_IN_URL, SIGN_UP_URL } from './constants'; import { type ContentSecurityPolicyOptions, createContentSecurityPolicyHeaders } from './content-security-policy'; import { errorThrower } from './errorThrower'; import { getHeader } from './headers-utils'; @@ -159,12 +160,16 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl ); // Handle Frontend API proxy requests early, before authentication - const frontendApiProxyConfig = resolvedParams.frontendApiProxy; + const requestUrl = new URL(request.url); + const frontendApiProxyConfig = + resolvedParams.frontendApiProxy ?? + (resolvedParams.proxyUrl || PROXY_URL || resolvedParams.domain || DOMAIN + ? undefined + : getAutoDetectedProxyConfig(requestUrl)); 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 })) { @@ -576,3 +581,10 @@ const handleControlFlowErrors = ( throw e; }; + +function getAutoDetectedProxyConfig(requestUrl: URL): FrontendApiProxyOptions | undefined { + if (isVercelPreviewDeploy(requestUrl.hostname)) { + return { enabled: true }; + } + return undefined; +} diff --git a/packages/shared/src/__tests__/proxy.spec.ts b/packages/shared/src/__tests__/proxy.spec.ts index 4a898391ee6..4ac55fb692b 100644 --- a/packages/shared/src/__tests__/proxy.spec.ts +++ b/packages/shared/src/__tests__/proxy.spec.ts @@ -1,6 +1,12 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { isHttpOrHttps, isProxyUrlRelative, isValidProxyUrl, proxyUrlToAbsoluteURL } from '../proxy'; +import { + isHttpOrHttps, + isProxyUrlRelative, + isValidProxyUrl, + isVercelPreviewDeploy, + proxyUrlToAbsoluteURL, +} from '../proxy'; describe('isValidProxyUrl(key)', () => { it('returns true if the proxyUrl is valid', () => { @@ -38,6 +44,28 @@ describe('isHttpOrHttps(key)', () => { }); }); +describe('isVercelPreviewDeploy(hostname)', () => { + it('returns true for a .vercel.app subdomain', () => { + expect(isVercelPreviewDeploy('myapp.vercel.app')).toBe(true); + }); + + it('returns true for a git branch preview subdomain', () => { + expect(isVercelPreviewDeploy('myapp-git-branch.vercel.app')).toBe(true); + }); + + it('returns false for the bare vercel.app domain', () => { + expect(isVercelPreviewDeploy('vercel.app')).toBe(false); + }); + + it('returns false for a custom domain', () => { + expect(isVercelPreviewDeploy('myapp.com')).toBe(false); + }); + + it('returns false for a domain that contains vercel.app but is not a subdomain', () => { + expect(isVercelPreviewDeploy('vercel.app.evil.com')).toBe(false); + }); +}); + describe('proxyUrlToAbsoluteURL(url)', () => { const currentLocation = global.window.location; diff --git a/packages/shared/src/proxy.ts b/packages/shared/src/proxy.ts index f7633ed1773..b4765a76ff8 100644 --- a/packages/shared/src/proxy.ts +++ b/packages/shared/src/proxy.ts @@ -33,6 +33,10 @@ export function proxyUrlToAbsoluteURL(url: string | undefined): string { return isProxyUrlRelative(url) ? new URL(url, window.location.origin).toString() : url; } +export function isVercelPreviewDeploy(hostname: string): boolean { + return hostname.endsWith('.vercel.app'); +} + /** * Function that determines whether proxy should be used for a given URL. */ From dbc580e972070ad430d781e0385cf5562bf926c2 Mon Sep 17 00:00:00 2001 From: brkalow Date: Thu, 12 Mar 2026 20:32:12 -0500 Subject: [PATCH 02/31] refactor: generalize auto proxy naming --- .../__tests__/authenticateContext.test.ts | 6 +++--- .../backend/src/tokens/authenticateContext.ts | 6 +++--- .../clerk-js/src/core/__tests__/clerk.test.ts | 6 +++--- packages/clerk-js/src/core/clerk.ts | 6 +++--- .../server/__tests__/clerkMiddleware.test.ts | 4 ++-- packages/nextjs/src/server/clerkMiddleware.ts | 4 ++-- packages/shared/src/__tests__/proxy.spec.ts | 20 +++++++------------ packages/shared/src/proxy.ts | 6 ++++-- 8 files changed, 27 insertions(+), 31 deletions(-) diff --git a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts index 576f4289ad5..643724cddbd 100644 --- a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts +++ b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts @@ -258,8 +258,8 @@ describe('AuthenticateContext', () => { }); }); - describe('auto-proxy for .vercel.app', () => { - it('auto-derives proxyUrl for .vercel.app hostnames', async () => { + describe('auto-proxy for eligible hosts', () => { + it('auto-derives proxyUrl for eligible hostnames', async () => { const clerkRequest = createClerkRequest(new Request('https://myapp-abc123.vercel.app/dashboard')); const context = await createAuthenticateContext(clerkRequest, { publishableKey: pkTest, @@ -268,7 +268,7 @@ describe('AuthenticateContext', () => { expect(context.proxyUrl).toBe('https://myapp-abc123.vercel.app/__clerk'); }); - it('does NOT auto-derive proxyUrl for non-.vercel.app domains', async () => { + it('does NOT auto-derive proxyUrl for ineligible domains', async () => { const clerkRequest = createClerkRequest(new Request('https://myapp.com/dashboard')); const context = await createAuthenticateContext(clerkRequest, { publishableKey: pkTest, diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index 3b0a1c5ee68..9b8477ef213 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -1,5 +1,5 @@ import { buildAccountsBaseUrl } from '@clerk/shared/buildAccountsBaseUrl'; -import { isVercelPreviewDeploy } from '@clerk/shared/proxy'; +import { shouldAutoProxy } from '@clerk/shared/proxy'; import type { Jwt } from '@clerk/shared/types'; import { isCurrentDevAccountPortalOrigin, isLegacyDevAccountPortalOrigin } from '@clerk/shared/url'; @@ -70,10 +70,10 @@ class AuthenticateContext implements AuthenticateContext { private clerkRequest: ClerkRequest, options: AuthenticateRequestOptions, ) { - // Auto-detect proxy for Vercel preview deployments + // Auto-detect proxy for supported platform deployments if (!options.proxyUrl && !options.domain) { const hostname = clerkRequest.clerkUrl.hostname; - if (isVercelPreviewDeploy(hostname)) { + if (shouldAutoProxy(hostname)) { options = { ...options, proxyUrl: `${clerkRequest.clerkUrl.origin}/__clerk` }; } } diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 41f7b6224d2..41176ab79ef 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -2492,7 +2492,7 @@ describe('Clerk singleton', () => { }); }); - describe('auto-detection for .vercel.app', () => { + describe('auto-detection for eligible hosts', () => { const originalLocation = window.location; afterEach(() => { @@ -2502,7 +2502,7 @@ describe('Clerk singleton', () => { }); }); - test('auto-derives proxyUrl when hostname is .vercel.app', () => { + test('auto-derives proxyUrl when hostname is eligible', () => { Object.defineProperty(window, 'location', { value: { ...originalLocation, @@ -2517,7 +2517,7 @@ describe('Clerk singleton', () => { expect(sut.proxyUrl).toBe('https://myapp-abc123.vercel.app/__clerk'); }); - test('does NOT auto-derive proxyUrl for non-.vercel.app domains', () => { + test('does NOT auto-derive proxyUrl for ineligible domains', () => { const sut = new Clerk(developmentPublishableKey); expect(sut.proxyUrl).toBe(''); }); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 68941e0707b..40babf405f3 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -38,7 +38,7 @@ import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate'; import { parsePublishableKey } from '@clerk/shared/keys'; import { logger } from '@clerk/shared/logger'; import { CLERK_NETLIFY_CACHE_BUST_PARAM } from '@clerk/shared/netlifyCacheHandler'; -import { isHttpOrHttps, isValidProxyUrl, isVercelPreviewDeploy, proxyUrlToAbsoluteURL } from '@clerk/shared/proxy'; +import { isHttpOrHttps, isValidProxyUrl, proxyUrlToAbsoluteURL, shouldAutoProxy } from '@clerk/shared/proxy'; import { eventPrebuiltComponentMounted, eventPrebuiltComponentOpened, @@ -355,8 +355,8 @@ export class Clerk implements ClerkInterface { if (resolved) { return resolved; } - // Auto-detect for Vercel preview deployments when no explicit proxy or domain is configured - if (!this.#domain && isVercelPreviewDeploy(window.location.hostname)) { + // Auto-detect when no explicit proxy or domain is configured + if (!this.#domain && shouldAutoProxy(window.location.hostname)) { return `${window.location.origin}/__clerk`; } } diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts index ba4ce124360..f5de6ea49b8 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -1191,8 +1191,8 @@ describe('frontendApiProxy multi-domain support', () => { }); }); -describe('auto-proxy for .vercel.app', () => { - it('auto-intercepts /__clerk/* requests on .vercel.app hostnames', async () => { +describe('auto-proxy for eligible hosts', () => { + it('auto-intercepts /__clerk/* requests on eligible hostnames', async () => { const req = new NextRequest(new URL('/__clerk/v1/client', 'https://myapp-abc123.vercel.app').toString(), { method: 'GET', headers: new Headers(), diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index bdb1e592b72..bde9f20738a 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -23,7 +23,7 @@ import { import { clerkFrontendApiProxy, DEFAULT_PROXY_PATH, matchProxyPath } from '@clerk/backend/proxy'; import { parsePublishableKey } from '@clerk/shared/keys'; import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler'; -import { isVercelPreviewDeploy } from '@clerk/shared/proxy'; +import { shouldAutoProxy } from '@clerk/shared/proxy'; import { notFound as nextjsNotFound } from 'next/navigation'; import type { NextMiddleware, NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; @@ -583,7 +583,7 @@ const handleControlFlowErrors = ( }; function getAutoDetectedProxyConfig(requestUrl: URL): FrontendApiProxyOptions | undefined { - if (isVercelPreviewDeploy(requestUrl.hostname)) { + if (shouldAutoProxy(requestUrl.hostname)) { return { enabled: true }; } return undefined; diff --git a/packages/shared/src/__tests__/proxy.spec.ts b/packages/shared/src/__tests__/proxy.spec.ts index 4ac55fb692b..499ab0cbf55 100644 --- a/packages/shared/src/__tests__/proxy.spec.ts +++ b/packages/shared/src/__tests__/proxy.spec.ts @@ -1,12 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { - isHttpOrHttps, - isProxyUrlRelative, - isValidProxyUrl, - isVercelPreviewDeploy, - proxyUrlToAbsoluteURL, -} from '../proxy'; +import { isHttpOrHttps, isProxyUrlRelative, isValidProxyUrl, proxyUrlToAbsoluteURL, shouldAutoProxy } from '../proxy'; describe('isValidProxyUrl(key)', () => { it('returns true if the proxyUrl is valid', () => { @@ -44,25 +38,25 @@ describe('isHttpOrHttps(key)', () => { }); }); -describe('isVercelPreviewDeploy(hostname)', () => { +describe('shouldAutoProxy(hostname)', () => { it('returns true for a .vercel.app subdomain', () => { - expect(isVercelPreviewDeploy('myapp.vercel.app')).toBe(true); + expect(shouldAutoProxy('myapp.vercel.app')).toBe(true); }); it('returns true for a git branch preview subdomain', () => { - expect(isVercelPreviewDeploy('myapp-git-branch.vercel.app')).toBe(true); + expect(shouldAutoProxy('myapp-git-branch.vercel.app')).toBe(true); }); it('returns false for the bare vercel.app domain', () => { - expect(isVercelPreviewDeploy('vercel.app')).toBe(false); + expect(shouldAutoProxy('vercel.app')).toBe(false); }); it('returns false for a custom domain', () => { - expect(isVercelPreviewDeploy('myapp.com')).toBe(false); + expect(shouldAutoProxy('myapp.com')).toBe(false); }); it('returns false for a domain that contains vercel.app but is not a subdomain', () => { - expect(isVercelPreviewDeploy('vercel.app.evil.com')).toBe(false); + expect(shouldAutoProxy('vercel.app.evil.com')).toBe(false); }); }); diff --git a/packages/shared/src/proxy.ts b/packages/shared/src/proxy.ts index b4765a76ff8..3bf5761d169 100644 --- a/packages/shared/src/proxy.ts +++ b/packages/shared/src/proxy.ts @@ -33,8 +33,10 @@ export function proxyUrlToAbsoluteURL(url: string | undefined): string { return isProxyUrlRelative(url) ? new URL(url, window.location.origin).toString() : url; } -export function isVercelPreviewDeploy(hostname: string): boolean { - return hostname.endsWith('.vercel.app'); +const AUTO_PROXY_HOST_SUFFIXES = ['.vercel.app']; + +export function shouldAutoProxy(hostname: string): boolean { + return AUTO_PROXY_HOST_SUFFIXES.some(hostSuffix => hostname.endsWith(hostSuffix)); } /** From 2d729d2f561474afc91ddc70bf5a72afa5d6d25c Mon Sep 17 00:00:00 2001 From: brkalow Date: Thu, 12 Mar 2026 20:41:01 -0500 Subject: [PATCH 03/31] chore(repo): add changeset for auto proxy updates --- .changeset/tiny-badgers-smile.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/tiny-badgers-smile.md diff --git a/.changeset/tiny-badgers-smile.md b/.changeset/tiny-badgers-smile.md new file mode 100644 index 00000000000..087f3b1c887 --- /dev/null +++ b/.changeset/tiny-badgers-smile.md @@ -0,0 +1,8 @@ +--- +'@clerk/backend': patch +'@clerk/clerk-js': patch +'@clerk/nextjs': patch +'@clerk/shared': patch +--- + +Add auto-proxy detection for eligible hosts and generalize the internal helper naming for future providers. From 72d6bdce37c39e6567f80aabbeef83b62858ce10 Mon Sep 17 00:00:00 2001 From: brkalow Date: Fri, 20 Mar 2026 13:38:01 -0500 Subject: [PATCH 04/31] feat(shared,nextjs): support vercel production static auto proxy --- .changeset/tiny-badgers-smile.md | 2 +- .../mergeNextClerkPropsWithEnv.test.ts | 75 +++++++++++++++++ .../src/utils/mergeNextClerkPropsWithEnv.ts | 17 +++- packages/shared/src/__tests__/proxy.spec.ts | 81 ++++++++++++++++++- packages/shared/src/proxy.ts | 41 ++++++++++ 5 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 packages/nextjs/src/utils/__tests__/mergeNextClerkPropsWithEnv.test.ts diff --git a/.changeset/tiny-badgers-smile.md b/.changeset/tiny-badgers-smile.md index 087f3b1c887..eb1c0bc9fa2 100644 --- a/.changeset/tiny-badgers-smile.md +++ b/.changeset/tiny-badgers-smile.md @@ -5,4 +5,4 @@ '@clerk/shared': patch --- -Add auto-proxy detection for eligible hosts and generalize the internal helper naming for future providers. +Add auto-proxy detection for eligible hosts, including Vercel production static-generation builds that can infer a relative proxy URL from platform env vars. diff --git a/packages/nextjs/src/utils/__tests__/mergeNextClerkPropsWithEnv.test.ts b/packages/nextjs/src/utils/__tests__/mergeNextClerkPropsWithEnv.test.ts new file mode 100644 index 00000000000..c83bdb54d0a --- /dev/null +++ b/packages/nextjs/src/utils/__tests__/mergeNextClerkPropsWithEnv.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { mergeNextClerkPropsWithEnv } from '../mergeNextClerkPropsWithEnv'; + +const ORIGINAL_ENV = { ...process.env }; + +describe('mergeNextClerkPropsWithEnv', () => { + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it('auto-derives a relative proxyUrl for Vercel production static generation', () => { + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = 'pk_live_Zm9vLmNsZXJrLmNvbSQ='; + process.env.VERCEL_TARGET_ENV = 'production'; + process.env.VERCEL_PROJECT_PRODUCTION_URL = 'myapp.vercel.app'; + + const result = mergeNextClerkPropsWithEnv({}); + + expect(result.proxyUrl).toBe('/__clerk'); + }); + + it('does not auto-derive proxyUrl for non-production Clerk keys', () => { + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = 'pk_test_Zm9vLmNsZXJrLmFjY291bnRzLmRldiQ='; + process.env.VERCEL_TARGET_ENV = 'production'; + process.env.VERCEL_PROJECT_PRODUCTION_URL = 'myapp.vercel.app'; + + const result = mergeNextClerkPropsWithEnv({}); + + expect(result.proxyUrl).toBe(''); + }); + + it('does not auto-derive proxyUrl outside Vercel production deployments', () => { + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = 'pk_live_Zm9vLmNsZXJrLmNvbSQ='; + process.env.VERCEL_TARGET_ENV = 'preview'; + process.env.VERCEL_PROJECT_PRODUCTION_URL = 'myapp.vercel.app'; + + const result = mergeNextClerkPropsWithEnv({}); + + expect(result.proxyUrl).toBe(''); + }); + + it('does not auto-derive proxyUrl when the Vercel production hostname is not eligible', () => { + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = 'pk_live_Zm9vLmNsZXJrLmNvbSQ='; + process.env.VERCEL_TARGET_ENV = 'production'; + process.env.VERCEL_PROJECT_PRODUCTION_URL = 'myapp.com'; + + const result = mergeNextClerkPropsWithEnv({}); + + expect(result.proxyUrl).toBe(''); + }); + + it('does not override an explicit proxyUrl', () => { + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = 'pk_live_Zm9vLmNsZXJrLmNvbSQ='; + process.env.VERCEL_TARGET_ENV = 'production'; + process.env.VERCEL_PROJECT_PRODUCTION_URL = 'myapp.vercel.app'; + + const result = mergeNextClerkPropsWithEnv({ + proxyUrl: 'https://custom-proxy.example.com/__clerk', + }); + + expect(result.proxyUrl).toBe('https://custom-proxy.example.com/__clerk'); + }); + + it('does not derive proxyUrl when an explicit domain is configured', () => { + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = 'pk_live_Zm9vLmNsZXJrLmNvbSQ='; + process.env.VERCEL_TARGET_ENV = 'production'; + process.env.VERCEL_PROJECT_PRODUCTION_URL = 'myapp.vercel.app'; + + const result = mergeNextClerkPropsWithEnv({ + domain: 'clerk.myapp.com', + }); + + expect(result.proxyUrl).toBe(''); + }); +}); diff --git a/packages/nextjs/src/utils/mergeNextClerkPropsWithEnv.ts b/packages/nextjs/src/utils/mergeNextClerkPropsWithEnv.ts index afb09022061..491e6cf810d 100644 --- a/packages/nextjs/src/utils/mergeNextClerkPropsWithEnv.ts +++ b/packages/nextjs/src/utils/mergeNextClerkPropsWithEnv.ts @@ -1,4 +1,5 @@ import type { InternalClerkScriptProps } from '@clerk/react/internal'; +import { getAutoProxyUrlFromEnvironment } from '@clerk/shared/proxy'; import { isTruthy } from '@clerk/shared/underscore'; import { SDK_METADATA } from '../server/constants'; @@ -22,16 +23,26 @@ function getPrefetchUIFromEnvAndProps(propsPrefetchUI: NextClerkProviderProps['p export const mergeNextClerkPropsWithEnv = ( props: Omit & InternalClerkScriptProps, ): any => { + const publishableKey = props.publishableKey || process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY || ''; + const proxyUrl = props.proxyUrl || process.env.NEXT_PUBLIC_CLERK_PROXY_URL || ''; + const domain = props.domain || process.env.NEXT_PUBLIC_CLERK_DOMAIN || ''; + return { ...props, - publishableKey: props.publishableKey || process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY || '', + publishableKey, __internal_clerkJSUrl: props.__internal_clerkJSUrl || process.env.NEXT_PUBLIC_CLERK_JS_URL, __internal_clerkJSVersion: props.__internal_clerkJSVersion || process.env.NEXT_PUBLIC_CLERK_JS_VERSION, __internal_clerkUIUrl: props.__internal_clerkUIUrl || process.env.NEXT_PUBLIC_CLERK_UI_URL, __internal_clerkUIVersion: props.__internal_clerkUIVersion || process.env.NEXT_PUBLIC_CLERK_UI_VERSION, prefetchUI: getPrefetchUIFromEnvAndProps(props.prefetchUI), - proxyUrl: props.proxyUrl || process.env.NEXT_PUBLIC_CLERK_PROXY_URL || '', - domain: props.domain || process.env.NEXT_PUBLIC_CLERK_DOMAIN || '', + proxyUrl: + proxyUrl || + getAutoProxyUrlFromEnvironment({ + hasDomain: !!domain, + hasProxyUrl: !!proxyUrl, + publishableKey, + }), + domain, isSatellite: props.isSatellite || isTruthy(process.env.NEXT_PUBLIC_CLERK_IS_SATELLITE), signInUrl: props.signInUrl || process.env.NEXT_PUBLIC_CLERK_SIGN_IN_URL || '', signUpUrl: props.signUpUrl || process.env.NEXT_PUBLIC_CLERK_SIGN_UP_URL || '', diff --git a/packages/shared/src/__tests__/proxy.spec.ts b/packages/shared/src/__tests__/proxy.spec.ts index 499ab0cbf55..48e47582890 100644 --- a/packages/shared/src/__tests__/proxy.spec.ts +++ b/packages/shared/src/__tests__/proxy.spec.ts @@ -1,6 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { isHttpOrHttps, isProxyUrlRelative, isValidProxyUrl, proxyUrlToAbsoluteURL, shouldAutoProxy } from '../proxy'; +import { + getAutoProxyUrlFromEnvironment, + isHttpOrHttps, + isProxyUrlRelative, + isValidProxyUrl, + proxyUrlToAbsoluteURL, + shouldAutoProxy, +} from '../proxy'; describe('isValidProxyUrl(key)', () => { it('returns true if the proxyUrl is valid', () => { @@ -60,6 +67,78 @@ describe('shouldAutoProxy(hostname)', () => { }); }); +describe('getAutoProxyUrlFromEnvironment(options)', () => { + it('returns a relative proxy path for Vercel production deployments with production keys', () => { + expect( + getAutoProxyUrlFromEnvironment({ + publishableKey: 'pk_live_Zm9vLmNsZXJrLmNvbSQ=', + environment: { + VERCEL_PROJECT_PRODUCTION_URL: 'myapp.vercel.app', + VERCEL_TARGET_ENV: 'production', + }, + }), + ).toBe('/__clerk'); + }); + + it('returns empty string for non-production Clerk keys', () => { + expect( + getAutoProxyUrlFromEnvironment({ + publishableKey: 'pk_test_Zm9vLmNsZXJrLmFjY291bnRzLmRldiQ=', + environment: { + VERCEL_PROJECT_PRODUCTION_URL: 'myapp.vercel.app', + VERCEL_TARGET_ENV: 'production', + }, + }), + ).toBe(''); + }); + + it('returns empty string when an explicit domain or proxyUrl is configured', () => { + expect( + getAutoProxyUrlFromEnvironment({ + hasDomain: true, + publishableKey: 'pk_live_Zm9vLmNsZXJrLmNvbSQ=', + environment: { + VERCEL_PROJECT_PRODUCTION_URL: 'myapp.vercel.app', + VERCEL_TARGET_ENV: 'production', + }, + }), + ).toBe(''); + + expect( + getAutoProxyUrlFromEnvironment({ + hasProxyUrl: true, + publishableKey: 'pk_live_Zm9vLmNsZXJrLmNvbSQ=', + environment: { + VERCEL_PROJECT_PRODUCTION_URL: 'myapp.vercel.app', + VERCEL_TARGET_ENV: 'production', + }, + }), + ).toBe(''); + }); + + it('returns empty string for ineligible or non-production Vercel environments', () => { + expect( + getAutoProxyUrlFromEnvironment({ + publishableKey: 'pk_live_Zm9vLmNsZXJrLmNvbSQ=', + environment: { + VERCEL_PROJECT_PRODUCTION_URL: 'myapp.com', + VERCEL_TARGET_ENV: 'production', + }, + }), + ).toBe(''); + + expect( + getAutoProxyUrlFromEnvironment({ + publishableKey: 'pk_live_Zm9vLmNsZXJrLmNvbSQ=', + environment: { + VERCEL_PROJECT_PRODUCTION_URL: 'myapp.vercel.app', + VERCEL_TARGET_ENV: 'preview', + }, + }), + ).toBe(''); + }); +}); + describe('proxyUrlToAbsoluteURL(url)', () => { const currentLocation = global.window.location; diff --git a/packages/shared/src/proxy.ts b/packages/shared/src/proxy.ts index 3bf5761d169..ca531ad9a88 100644 --- a/packages/shared/src/proxy.ts +++ b/packages/shared/src/proxy.ts @@ -1,3 +1,5 @@ +import { isProductionFromPublishableKey } from './keys'; + /** * */ @@ -34,11 +36,50 @@ export function proxyUrlToAbsoluteURL(url: string | undefined): string { } const AUTO_PROXY_HOST_SUFFIXES = ['.vercel.app']; +const AUTO_PROXY_PATH = '/__clerk'; export function shouldAutoProxy(hostname: string): boolean { return AUTO_PROXY_HOST_SUFFIXES.some(hostSuffix => hostname.endsWith(hostSuffix)); } +function normalizeHostname(hostnameOrUrl: string): string { + if (hostnameOrUrl.startsWith('http://') || hostnameOrUrl.startsWith('https://')) { + return new URL(hostnameOrUrl).hostname; + } + + return hostnameOrUrl.split('/')[0] || ''; +} + +type GetAutoProxyUrlFromEnvironmentOptions = { + publishableKey: string; + hasDomain?: boolean; + hasProxyUrl?: boolean; + environment?: NodeJS.ProcessEnv; +}; + +export function getAutoProxyUrlFromEnvironment({ + publishableKey, + hasDomain = false, + hasProxyUrl = false, + environment = process.env, +}: GetAutoProxyUrlFromEnvironmentOptions): string { + if (hasProxyUrl || hasDomain || !isProductionFromPublishableKey(publishableKey)) { + return ''; + } + + if (environment.VERCEL_TARGET_ENV !== 'production') { + return ''; + } + + const vercelProductionHostname = environment.VERCEL_PROJECT_PRODUCTION_URL; + + if (!vercelProductionHostname || !shouldAutoProxy(normalizeHostname(vercelProductionHostname))) { + return ''; + } + + return AUTO_PROXY_PATH; +} + /** * Function that determines whether proxy should be used for a given URL. */ From f8906d377e327fa8dc917181facc8078a0ef17ed Mon Sep 17 00:00:00 2001 From: brkalow Date: Fri, 20 Mar 2026 14:06:21 -0500 Subject: [PATCH 05/31] fix(shared): make proxy helpers server safe --- .../src/__tests__/loadClerkJsScript.spec.ts | 20 +++++++++++++++++++ packages/shared/src/__tests__/proxy.spec.ts | 18 +++++++++++++++++ packages/shared/src/loadClerkJsScript.ts | 10 ++++++++-- packages/shared/src/proxy.ts | 11 +++++++++- 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/__tests__/loadClerkJsScript.spec.ts b/packages/shared/src/__tests__/loadClerkJsScript.spec.ts index 69eeedf576e..cbe32c1f982 100644 --- a/packages/shared/src/__tests__/loadClerkJsScript.spec.ts +++ b/packages/shared/src/__tests__/loadClerkJsScript.spec.ts @@ -228,6 +228,26 @@ describe('buildScriptHost()', () => { writable: true, }); }); + + test('falls back to frontendApi for relative proxyUrl when window is unavailable', () => { + const currentWindow = global.window; + + try { + Object.defineProperty(global, 'window', { + value: undefined, + configurable: true, + }); + + const result = buildScriptHost({ publishableKey: mockDevPublishableKey, proxyUrl: '/__clerk' }); + expect(result).toBe('foo-bar-13.clerk.accounts.dev'); + } finally { + Object.defineProperty(global, 'window', { + value: currentWindow, + writable: true, + configurable: true, + }); + } + }); }); describe('buildClerkJsScriptAttributes()', () => { diff --git a/packages/shared/src/__tests__/proxy.spec.ts b/packages/shared/src/__tests__/proxy.spec.ts index 48e47582890..b09143e2f4a 100644 --- a/packages/shared/src/__tests__/proxy.spec.ts +++ b/packages/shared/src/__tests__/proxy.spec.ts @@ -167,6 +167,24 @@ describe('proxyUrlToAbsoluteURL(url)', () => { it('returns the same value as the parameter given as it already an absolute URL', () => { expect(proxyUrlToAbsoluteURL('https://clerk.com/api/__clerk')).toBe('https://clerk.com/api/__clerk'); }); + + it('returns the relative URL unchanged when window is unavailable', () => { + const currentWindow = global.window; + + Object.defineProperty(global, 'window', { + value: undefined, + configurable: true, + }); + + expect(proxyUrlToAbsoluteURL('/api/__clerk')).toBe('/api/__clerk'); + + Object.defineProperty(global, 'window', { + value: currentWindow, + writable: true, + configurable: true, + }); + }); + it('returns empty string if parameter is undefined', () => { expect(proxyUrlToAbsoluteURL(undefined)).toBe(''); }); diff --git a/packages/shared/src/loadClerkJsScript.ts b/packages/shared/src/loadClerkJsScript.ts index 96171f5c648..1cfce88a78d 100644 --- a/packages/shared/src/loadClerkJsScript.ts +++ b/packages/shared/src/loadClerkJsScript.ts @@ -1,7 +1,7 @@ import { buildErrorThrower, ClerkRuntimeError } from './error'; import { createDevOrStagingUrlCache, parsePublishableKey } from './keys'; import { loadScript } from './loadScript'; -import { isValidProxyUrl, proxyUrlToAbsoluteURL } from './proxy'; +import { isProxyUrlRelative, isValidProxyUrl, proxyUrlToAbsoluteURL } from './proxy'; import type { SDKMetadata } from './types'; import { addClerkPrefix } from './url'; import { versionSelector } from './versionSelector'; @@ -284,7 +284,13 @@ export const buildScriptHost = (opts: { publishableKey: string; proxyUrl?: strin const { proxyUrl, domain, publishableKey } = opts; if (!!proxyUrl && isValidProxyUrl(proxyUrl)) { - return proxyUrlToAbsoluteURL(proxyUrl).replace(/http(s)?:\/\//, ''); + const resolvedProxyUrl = proxyUrlToAbsoluteURL(proxyUrl); + + if (isProxyUrlRelative(resolvedProxyUrl)) { + return parsePublishableKey(publishableKey)?.frontendApi || ''; + } + + return resolvedProxyUrl.replace(/http(s)?:\/\//, ''); } else if (domain && !isDevOrStagingUrl(parsePublishableKey(publishableKey)?.frontendApi || '')) { return addClerkPrefix(domain); } else { diff --git a/packages/shared/src/proxy.ts b/packages/shared/src/proxy.ts index ca531ad9a88..992f848460b 100644 --- a/packages/shared/src/proxy.ts +++ b/packages/shared/src/proxy.ts @@ -32,7 +32,16 @@ export function proxyUrlToAbsoluteURL(url: string | undefined): string { if (!url) { return ''; } - return isProxyUrlRelative(url) ? new URL(url, window.location.origin).toString() : url; + + if (!isProxyUrlRelative(url)) { + return url; + } + + if (typeof window === 'undefined' || !window.location?.origin) { + return url; + } + + return new URL(url, window.location.origin).toString(); } const AUTO_PROXY_HOST_SUFFIXES = ['.vercel.app']; From 217a99174d86024ca17197950ca988f835fd0120 Mon Sep 17 00:00:00 2001 From: brkalow Date: Fri, 20 Mar 2026 14:24:31 -0500 Subject: [PATCH 06/31] fix(nextjs): use nextUrl for auto proxy detection --- .../server/__tests__/clerkMiddleware.test.ts | 17 +++++++++++++++++ packages/nextjs/src/server/clerkMiddleware.ts | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts index f5de6ea49b8..b698188b92f 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -1204,6 +1204,23 @@ describe('auto-proxy for eligible hosts', () => { expect((await clerkClient()).authenticateRequest).not.toBeCalled(); expect(resp?.status).toBeDefined(); }); + + it('uses request.nextUrl for auto-detection', async () => { + const req = new NextRequest('http://127.0.0.1:3000/__clerk/v1/client', { + method: 'GET', + headers: new Headers(), + }); + + Object.defineProperty(req, 'nextUrl', { + value: new URL('https://myapp-abc123.vercel.app/__clerk/v1/client'), + configurable: true, + }); + + const resp = await clerkMiddleware()(req, {} as NextFetchEvent); + + expect((await clerkClient()).authenticateRequest).not.toBeCalled(); + expect(resp?.status).toBeDefined(); + }); }); describe('contentSecurityPolicy option', () => { diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index bde9f20738a..43fd794131a 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -160,7 +160,7 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl ); // Handle Frontend API proxy requests early, before authentication - const requestUrl = new URL(request.url); + const requestUrl = new URL(request.nextUrl.href); const frontendApiProxyConfig = resolvedParams.frontendApiProxy ?? (resolvedParams.proxyUrl || PROXY_URL || resolvedParams.domain || DOMAIN From db5ee3fda433b3a4636cbe2ed73c9d2c8813fd58 Mon Sep 17 00:00:00 2001 From: brkalow Date: Fri, 20 Mar 2026 15:22:58 -0500 Subject: [PATCH 07/31] fix(shared,nextjs): proxy initial script tags --- .../__tests__/DynamicClerkScripts.test.tsx | 20 ++++++++++++++ .../src/__tests__/loadClerkJsScript.spec.ts | 10 +++++++ packages/shared/src/loadClerkJsScript.ts | 26 +++++++++++++++++-- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/nextjs/src/app-router/server/__tests__/DynamicClerkScripts.test.tsx b/packages/nextjs/src/app-router/server/__tests__/DynamicClerkScripts.test.tsx index e9c787bc1f8..2e4a4111e40 100644 --- a/packages/nextjs/src/app-router/server/__tests__/DynamicClerkScripts.test.tsx +++ b/packages/nextjs/src/app-router/server/__tests__/DynamicClerkScripts.test.tsx @@ -86,4 +86,24 @@ describe('DynamicClerkScripts', () => { expect(html).not.toContain('nonce="test'); expect(html).not.toContain('nonce="csp'); }); + + it('renders initial script tags with relative proxied asset URLs', async () => { + mockHeaders.mockResolvedValue( + new Map([ + ['X-Nonce', null], + ['Content-Security-Policy', ''], + ]), + ); + + const html = await render( + DynamicClerkScripts({ + ...defaultProps, + proxyUrl: '/__clerk', + }), + ); + + expect(html).toContain('src="/__clerk/npm/@clerk/clerk-js@'); + expect(html).toContain('href="/__clerk/npm/@clerk/ui@'); + expect(html).toContain('data-clerk-proxy-url="/__clerk"'); + }); }); diff --git a/packages/shared/src/__tests__/loadClerkJsScript.spec.ts b/packages/shared/src/__tests__/loadClerkJsScript.spec.ts index cbe32c1f982..81191d47072 100644 --- a/packages/shared/src/__tests__/loadClerkJsScript.spec.ts +++ b/packages/shared/src/__tests__/loadClerkJsScript.spec.ts @@ -169,6 +169,11 @@ describe('clerkJsScriptUrl()', () => { const result = clerkJsScriptUrl({ publishableKey: mockDevPublishableKey, __internal_clerkJSVersion: '6' }); expect(result).toContain('/npm/@clerk/clerk-js@6/'); }); + + test('constructs a relative proxied URL when proxyUrl is relative', () => { + const result = clerkJsScriptUrl({ publishableKey: mockDevPublishableKey, proxyUrl: '/__clerk' }); + expect(result).toBe(`/__clerk/npm/@clerk/clerk-js@${jsPackageMajorVersion}/dist/clerk.browser.js`); + }); }); describe('buildScriptHost()', () => { @@ -445,6 +450,11 @@ describe('clerkUIScriptUrl()', () => { expect(uiResult).not.toContain('@clerk/clerk-js'); expect(jsResult).not.toContain('@clerk/ui'); }); + + test('constructs a relative proxied URL when proxyUrl is relative', () => { + const result = clerkUIScriptUrl({ publishableKey: mockDevPublishableKey, proxyUrl: '/__clerk' }); + expect(result).toBe(`/__clerk/npm/@clerk/ui@${uiPackageMajorVersion}/dist/ui.browser.js`); + }); }); describe('buildClerkUIScriptAttributes()', () => { diff --git a/packages/shared/src/loadClerkJsScript.ts b/packages/shared/src/loadClerkJsScript.ts index 1cfce88a78d..917c34268e1 100644 --- a/packages/shared/src/loadClerkJsScript.ts +++ b/packages/shared/src/loadClerkJsScript.ts @@ -230,8 +230,13 @@ export const clerkJSScriptUrl = (opts: LoadClerkJSScriptOptions) => { return __internal_clerkJSUrl; } - const scriptHost = buildScriptHost({ publishableKey, proxyUrl, domain }); const version = versionSelector(__internal_clerkJSVersion); + + if (proxyUrl && isProxyUrlRelative(proxyUrl)) { + return buildRelativeProxyScriptUrl(proxyUrl, 'clerk-js', version, 'clerk.browser.js'); + } + + const scriptHost = buildScriptHost({ publishableKey, proxyUrl, domain }); return `https://${scriptHost}/npm/@clerk/clerk-js@${version}/dist/clerk.browser.js`; }; @@ -242,8 +247,13 @@ export const clerkUIScriptUrl = (opts: LoadClerkUIScriptOptions) => { return __internal_clerkUIUrl; } - const scriptHost = buildScriptHost({ publishableKey, proxyUrl, domain }); const version = versionSelector(__internal_clerkUIVersion, UI_PACKAGE_VERSION); + + if (proxyUrl && isProxyUrlRelative(proxyUrl)) { + return buildRelativeProxyScriptUrl(proxyUrl, 'ui', version, 'ui.browser.js'); + } + + const scriptHost = buildScriptHost({ publishableKey, proxyUrl, domain }); return `https://${scriptHost}/npm/@clerk/ui@${version}/dist/ui.browser.js`; }; @@ -280,6 +290,18 @@ const applyAttributesToScript = (attributes: Record) => (script: } }; +const stripTrailingSlashes = (value: string) => { + while (value.endsWith('/')) { + value = value.slice(0, -1); + } + + return value; +}; + +const buildRelativeProxyScriptUrl = (proxyUrl: string, packageName: string, version: string, fileName: string) => { + return `${stripTrailingSlashes(proxyUrl)}/npm/@clerk/${packageName}@${version}/dist/${fileName}`; +}; + export const buildScriptHost = (opts: { publishableKey: string; proxyUrl?: string; domain?: string }) => { const { proxyUrl, domain, publishableKey } = opts; From c99aba227649cbbc135a0aada395249d84280bf5 Mon Sep 17 00:00:00 2001 From: brkalow Date: Fri, 20 Mar 2026 16:14:12 -0500 Subject: [PATCH 08/31] fix(backend): prevent Content-Encoding mismatch in proxy responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetch() auto-decompresses response bodies but may leave Content-Encoding and Content-Length headers intact, causing ERR_CONTENT_DECODING_FAILED in the browser. Fix with two layers: 1. Request `Accept-Encoding: identity` upstream to avoid a double compression pass (FAPI → fetch decompresses → edge re-compresses) 2. Defensively strip Content-Encoding and Content-Length from responses since servers may ignore the identity hint Co-Authored-By: Claude Opus 4.6 --- packages/backend/src/__tests__/proxy.test.ts | 44 ++++++++++++++++++++ packages/backend/src/proxy.ts | 18 +++++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/__tests__/proxy.test.ts b/packages/backend/src/__tests__/proxy.test.ts index fdc54b47f51..672aaaf32b9 100644 --- a/packages/backend/src/__tests__/proxy.test.ts +++ b/packages/backend/src/__tests__/proxy.test.ts @@ -481,6 +481,50 @@ describe('proxy', () => { expect(response.headers.get('Location')).toBe('https://accounts.google.com/oauth/authorize'); }); + it('sets Accept-Encoding to identity to avoid double compression', async () => { + const mockResponse = new Response(JSON.stringify({ client: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + mockFetch.mockResolvedValue(mockResponse); + + const request = new Request('https://example.com/__clerk/v1/client', { + headers: { 'Accept-Encoding': 'gzip, deflate, br' }, + }); + + await clerkFrontendApiProxy(request, { + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + secretKey: 'sk_test_xxx', + }); + + const [, options] = mockFetch.mock.calls[0]; + expect(options.headers.get('Accept-Encoding')).toBe('identity'); + }); + + it('strips Content-Encoding and Content-Length from response even if upstream ignores identity', async () => { + // Upstream may ignore Accept-Encoding: identity and compress anyway + const mockResponse = new Response('decoded body', { + status: 200, + headers: { + 'Content-Type': 'application/javascript', + 'Content-Encoding': 'gzip', + 'Content-Length': '500', + }, + }); + 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', + }); + + expect(response.headers.has('Content-Encoding')).toBe(false); + expect(response.headers.has('Content-Length')).toBe(false); + expect(response.headers.get('Content-Type')).toBe('application/javascript'); + }); + it('preserves relative Location headers', async () => { const mockResponse = new Response(null, { status: 302, diff --git a/packages/backend/src/proxy.ts b/packages/backend/src/proxy.ts index 96b6bad11a3..62c93359bf5 100644 --- a/packages/backend/src/proxy.ts +++ b/packages/backend/src/proxy.ts @@ -54,6 +54,13 @@ const HOP_BY_HOP_HEADERS = [ 'upgrade', ]; +// 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']; + /** * Derives the Frontend API URL from a publishable key. * @param publishableKey - The Clerk publishable key @@ -235,6 +242,12 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend const fapiHost = new URL(fapiBaseUrl).host; headers.set('Host', fapiHost); + // Request uncompressed responses to avoid a double compression pass. + // fetch() auto-decompresses, so without this FAPI compresses → fetch + // decompresses → the serving layer re-compresses for the browser. + // With identity the only compression happens at the edge, closer to the client. + headers.set('Accept-Encoding', 'identity'); + // Set X-Forwarded-* headers for proxy awareness // Only set these if not already present (preserve values from upstream proxies) if (!headers.has('X-Forwarded-Host')) { @@ -271,10 +284,11 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend const response = await fetch(targetUrl.toString(), fetchOptions); - // Build response headers, excluding hop-by-hop headers + // Build response headers, excluding hop-by-hop and encoding headers const responseHeaders = new Headers(); response.headers.forEach((value, key) => { - if (!HOP_BY_HOP_HEADERS.includes(key.toLowerCase())) { + const lower = key.toLowerCase(); + if (!HOP_BY_HOP_HEADERS.includes(lower) && !RESPONSE_HEADERS_TO_STRIP.includes(lower)) { responseHeaders.set(key, value); } }); From aea396690067ac61b04c0c6bd91d28bef4d6cfc9 Mon Sep 17 00:00:00 2001 From: brkalow Date: Tue, 24 Mar 2026 11:42:04 -0500 Subject: [PATCH 09/31] fix: add Vercel env vars to turbo.json and harden proxy header stripping - Add VERCEL_TARGET_ENV and VERCEL_PROJECT_PRODUCTION_URL to turbo.json globalEnv to fix turbo/no-undeclared-env-vars lint errors - Explicitly delete Content-Encoding/Content-Length after Response construction for runtimes that re-add headers Co-Authored-By: Claude Opus 4.6 --- packages/backend/src/proxy.ts | 11 ++++++++++- turbo.json | 2 ++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/proxy.ts b/packages/backend/src/proxy.ts index 62c93359bf5..4dc52aa75fd 100644 --- a/packages/backend/src/proxy.ts +++ b/packages/backend/src/proxy.ts @@ -309,11 +309,20 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend } } - return new Response(response.body, { + const proxyResponse = new Response(response.body, { status: response.status, statusText: response.statusText, headers: responseHeaders, }); + + // Some runtimes (e.g. Node 20) may re-add Content-Length when constructing + // the Response. Delete it explicitly since the body has been decompressed + // by fetch() and the original Content-Length is no longer accurate. + for (const header of RESPONSE_HEADERS_TO_STRIP) { + proxyResponse.headers.delete(header); + } + + return proxyResponse; } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; return createErrorResponse('proxy_request_failed', `Failed to proxy request to Clerk FAPI: ${message}`, 502); diff --git a/turbo.json b/turbo.json index 3059180e78f..c6b9f1ffb69 100644 --- a/turbo.json +++ b/turbo.json @@ -27,6 +27,8 @@ "RSDOCTOR", "TZ", "VERCEL", + "VERCEL_PROJECT_PRODUCTION_URL", + "VERCEL_TARGET_ENV", "VITE_CLERK_*" ], "globalPassThroughEnv": [ From 675270a76fabc0529cd4b052e75b3d14da894ef2 Mon Sep 17 00:00:00 2001 From: brkalow Date: Tue, 31 Mar 2026 10:46:45 -0500 Subject: [PATCH 10/31] refactor(backend): convert RESPONSE_HEADERS_TO_STRIP to a Set Co-Authored-By: Claude Opus 4.6 --- packages/backend/src/proxy.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/proxy.ts b/packages/backend/src/proxy.ts index 4dc52aa75fd..833dfc657ac 100644 --- a/packages/backend/src/proxy.ts +++ b/packages/backend/src/proxy.ts @@ -59,7 +59,7 @@ const HOP_BY_HOP_HEADERS = [ // 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. @@ -288,7 +288,7 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend 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.includes(lower) && !RESPONSE_HEADERS_TO_STRIP.has(lower)) { responseHeaders.set(key, value); } }); From bdc84360ce2a67a48b021ed97d1119bdbd5073a5 Mon Sep 17 00:00:00 2001 From: Railly Date: Fri, 10 Apr 2026 16:28:37 -0500 Subject: [PATCH 11/31] chore: add changeset for auto-proxy feature --- .changeset/auto-proxy-vercel-subdomains.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/auto-proxy-vercel-subdomains.md diff --git a/.changeset/auto-proxy-vercel-subdomains.md b/.changeset/auto-proxy-vercel-subdomains.md new file mode 100644 index 00000000000..54671b8867d --- /dev/null +++ b/.changeset/auto-proxy-vercel-subdomains.md @@ -0,0 +1,8 @@ +--- +'@clerk/shared': patch +'@clerk/backend': patch +'@clerk/clerk-js': patch +'@clerk/nextjs': patch +--- + +Auto-proxy FAPI requests for `.vercel.app` subdomains. When deployed to a `.vercel.app` domain without explicit proxy or domain configuration, the SDK automatically routes Frontend API requests through `/__clerk` on the app's own origin. This enables Clerk production mode on Vercel deployments without manual proxy setup. From 9a2f6b7742b1daa85243039a35bc036943274220 Mon Sep 17 00:00:00 2001 From: Railly Date: Fri, 10 Apr 2026 16:28:50 -0500 Subject: [PATCH 12/31] fix(shared): guard shouldAutoProxy against undefined hostname --- packages/shared/src/proxy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/proxy.ts b/packages/shared/src/proxy.ts index 992f848460b..e721d97cc5e 100644 --- a/packages/shared/src/proxy.ts +++ b/packages/shared/src/proxy.ts @@ -48,7 +48,7 @@ const AUTO_PROXY_HOST_SUFFIXES = ['.vercel.app']; const AUTO_PROXY_PATH = '/__clerk'; export function shouldAutoProxy(hostname: string): boolean { - return AUTO_PROXY_HOST_SUFFIXES.some(hostSuffix => hostname.endsWith(hostSuffix)); + return AUTO_PROXY_HOST_SUFFIXES.some(hostSuffix => hostname?.endsWith(hostSuffix)) ?? false; } function normalizeHostname(hostnameOrUrl: string): string { From b89156db77f0af688c6b4aafcac79c22365494ce Mon Sep 17 00:00:00 2001 From: Railly Date: Fri, 10 Apr 2026 16:28:56 -0500 Subject: [PATCH 13/31] chore: remove duplicate changeset --- .changeset/tiny-badgers-smile.md | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 .changeset/tiny-badgers-smile.md diff --git a/.changeset/tiny-badgers-smile.md b/.changeset/tiny-badgers-smile.md deleted file mode 100644 index eb1c0bc9fa2..00000000000 --- a/.changeset/tiny-badgers-smile.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@clerk/backend': patch -'@clerk/clerk-js': patch -'@clerk/nextjs': patch -'@clerk/shared': patch ---- - -Add auto-proxy detection for eligible hosts, including Vercel production static-generation builds that can infer a relative proxy URL from platform env vars. From 0b6db2b5669c9bc58382158a8b18f1021c132fd7 Mon Sep 17 00:00:00 2001 From: Railly Date: Tue, 14 Apr 2026 16:30:04 -0500 Subject: [PATCH 14/31] fix: only auto-proxy for production instances on .vercel.app --- packages/backend/src/tokens/authenticateContext.ts | 4 ++-- packages/clerk-js/src/core/clerk.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index c8304a6302f..60fc2aa5e95 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -71,8 +71,8 @@ class AuthenticateContext implements AuthenticateContext { private clerkRequest: ClerkRequest, options: AuthenticateRequestOptions, ) { - // Auto-detect proxy for supported platform deployments - if (!options.proxyUrl && !options.domain) { + // Auto-detect proxy for supported platform deployments (production only) + if (!options.proxyUrl && !options.domain && options.publishableKey?.startsWith('pk_live_')) { const hostname = clerkRequest.clerkUrl.hostname; if (shouldAutoProxy(hostname)) { options = { ...options, proxyUrl: `${clerkRequest.clerkUrl.origin}/__clerk` }; diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 507b3d886a1..a7e997566d2 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -364,8 +364,8 @@ export class Clerk implements ClerkInterface { if (resolved) { return resolved; } - // Auto-detect when no explicit proxy or domain is configured - if (!this.#domain && shouldAutoProxy(window.location.hostname)) { + // Auto-detect when no explicit proxy or domain is configured (production only) + if (!this.#domain && this.#instanceType === 'production' && shouldAutoProxy(window.location.hostname)) { return `${window.location.origin}/__clerk`; } } From 96842db05fe56f29c8f12d5e2f472177bf7f1598 Mon Sep 17 00:00:00 2001 From: Railly Date: Tue, 14 Apr 2026 16:30:46 -0500 Subject: [PATCH 15/31] refactor: export AUTO_PROXY_PATH and replace hardcoded /__clerk --- packages/backend/src/tokens/authenticateContext.ts | 4 ++-- packages/clerk-js/src/core/clerk.ts | 10 ++++++++-- packages/shared/src/proxy.ts | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index 60fc2aa5e95..914f85dce38 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -1,5 +1,5 @@ import { buildAccountsBaseUrl } from '@clerk/shared/buildAccountsBaseUrl'; -import { shouldAutoProxy } from '@clerk/shared/proxy'; +import { AUTO_PROXY_PATH, shouldAutoProxy } from '@clerk/shared/proxy'; import type { Jwt } from '@clerk/shared/types'; import { isCurrentDevAccountPortalOrigin, isLegacyDevAccountPortalOrigin } from '@clerk/shared/url'; @@ -75,7 +75,7 @@ class AuthenticateContext implements AuthenticateContext { if (!options.proxyUrl && !options.domain && options.publishableKey?.startsWith('pk_live_')) { const hostname = clerkRequest.clerkUrl.hostname; if (shouldAutoProxy(hostname)) { - options = { ...options, proxyUrl: `${clerkRequest.clerkUrl.origin}/__clerk` }; + options = { ...options, proxyUrl: `${clerkRequest.clerkUrl.origin}${AUTO_PROXY_PATH}` }; } } diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index a7e997566d2..3f6662a65b7 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -38,7 +38,13 @@ import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate'; import { parsePublishableKey } from '@clerk/shared/keys'; import { logger } from '@clerk/shared/logger'; import { CLERK_NETLIFY_CACHE_BUST_PARAM } from '@clerk/shared/netlifyCacheHandler'; -import { isHttpOrHttps, isValidProxyUrl, proxyUrlToAbsoluteURL, shouldAutoProxy } from '@clerk/shared/proxy'; +import { + AUTO_PROXY_PATH, + isHttpOrHttps, + isValidProxyUrl, + proxyUrlToAbsoluteURL, + shouldAutoProxy, +} from '@clerk/shared/proxy'; import { eventPrebuiltComponentMounted, eventPrebuiltComponentOpened, @@ -366,7 +372,7 @@ export class Clerk implements ClerkInterface { } // Auto-detect when no explicit proxy or domain is configured (production only) if (!this.#domain && this.#instanceType === 'production' && shouldAutoProxy(window.location.hostname)) { - return `${window.location.origin}/__clerk`; + return `${window.location.origin}${AUTO_PROXY_PATH}`; } } diff --git a/packages/shared/src/proxy.ts b/packages/shared/src/proxy.ts index e721d97cc5e..2b8b1310a90 100644 --- a/packages/shared/src/proxy.ts +++ b/packages/shared/src/proxy.ts @@ -45,7 +45,7 @@ export function proxyUrlToAbsoluteURL(url: string | undefined): string { } const AUTO_PROXY_HOST_SUFFIXES = ['.vercel.app']; -const AUTO_PROXY_PATH = '/__clerk'; +export const AUTO_PROXY_PATH = '/__clerk'; export function shouldAutoProxy(hostname: string): boolean { return AUTO_PROXY_HOST_SUFFIXES.some(hostSuffix => hostname?.endsWith(hostSuffix)) ?? false; From 635c9b7a1b9e379661b5e3f637b2a7389bec23a7 Mon Sep 17 00:00:00 2001 From: Railly Date: Tue, 14 Apr 2026 16:31:32 -0500 Subject: [PATCH 16/31] refactor(nextjs): inline auto-proxy detection in middleware for readability --- packages/nextjs/src/server/clerkMiddleware.ts | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index 43fd794131a..6c3b4a9a62a 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -160,12 +160,16 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl ); // Handle Frontend API proxy requests early, before authentication - const requestUrl = new URL(request.nextUrl.href); - const frontendApiProxyConfig = - resolvedParams.frontendApiProxy ?? - (resolvedParams.proxyUrl || PROXY_URL || resolvedParams.domain || DOMAIN - ? undefined - : getAutoDetectedProxyConfig(requestUrl)); + let frontendApiProxyConfig = resolvedParams.frontendApiProxy; + + // Auto-detect when no explicit proxy or domain is configured + const hasExplicitProxyOrDomain = resolvedParams.proxyUrl || PROXY_URL || resolvedParams.domain || DOMAIN; + if (!frontendApiProxyConfig && !hasExplicitProxyOrDomain) { + const requestUrl = new URL(request.nextUrl.href); + if (shouldAutoProxy(requestUrl.hostname)) { + frontendApiProxyConfig = { enabled: true }; + } + } if (frontendApiProxyConfig) { const { enabled, path: proxyPath = DEFAULT_PROXY_PATH } = frontendApiProxyConfig; @@ -581,10 +585,3 @@ const handleControlFlowErrors = ( throw e; }; - -function getAutoDetectedProxyConfig(requestUrl: URL): FrontendApiProxyOptions | undefined { - if (shouldAutoProxy(requestUrl.hostname)) { - return { enabled: true }; - } - return undefined; -} From 08597080b6437ea3494a86b6a658b4b6e35c3781 Mon Sep 17 00:00:00 2001 From: Railly Date: Tue, 14 Apr 2026 16:32:19 -0500 Subject: [PATCH 17/31] test(clerk-js): add dev instance guard test, use production key for auto-proxy tests --- .../clerk-js/src/core/__tests__/clerk.test.ts | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 150dc9537c4..979cf6e24fa 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -2527,7 +2527,7 @@ describe('Clerk singleton', () => { }); }); - test('auto-derives proxyUrl when hostname is eligible', () => { + test('auto-derives proxyUrl for production instances on eligible hosts', () => { Object.defineProperty(window, 'location', { value: { ...originalLocation, @@ -2538,15 +2538,30 @@ describe('Clerk singleton', () => { writable: true, }); - const sut = new Clerk(developmentPublishableKey); + const sut = new Clerk(productionPublishableKey); expect(sut.proxyUrl).toBe('https://myapp-abc123.vercel.app/__clerk'); }); - test('does NOT auto-derive proxyUrl for ineligible domains', () => { + test('does NOT auto-derive proxyUrl for development instances on eligible hosts', () => { + Object.defineProperty(window, 'location', { + value: { + ...originalLocation, + hostname: 'myapp-abc123.vercel.app', + origin: 'https://myapp-abc123.vercel.app', + href: 'https://myapp-abc123.vercel.app/dashboard', + }, + writable: true, + }); + const sut = new Clerk(developmentPublishableKey); expect(sut.proxyUrl).toBe(''); }); + test('does NOT auto-derive proxyUrl for ineligible domains', () => { + const sut = new Clerk(productionPublishableKey); + expect(sut.proxyUrl).toBe(''); + }); + test('explicit proxyUrl takes precedence over auto-detection', () => { Object.defineProperty(window, 'location', { value: { @@ -2558,7 +2573,7 @@ describe('Clerk singleton', () => { writable: true, }); - const sut = new Clerk(developmentPublishableKey, { + const sut = new Clerk(productionPublishableKey, { proxyUrl: 'https://custom-proxy.example.com/__clerk', }); expect(sut.proxyUrl).toBe('https://custom-proxy.example.com/__clerk'); @@ -2575,7 +2590,7 @@ describe('Clerk singleton', () => { writable: true, }); - const sut = new Clerk(developmentPublishableKey, { + const sut = new Clerk(productionPublishableKey, { domain: 'clerk.myapp.com', }); expect(sut.proxyUrl).toBe(''); From 67475c6ef039faa72323b8f548f17677aaa584da Mon Sep 17 00:00:00 2001 From: Railly Date: Thu, 16 Apr 2026 16:25:15 -0500 Subject: [PATCH 18/31] fix(nextjs): add production-only guard to middleware auto-proxy detection --- packages/nextjs/src/server/clerkMiddleware.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index 6c3b4a9a62a..ce5e95511ab 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -164,7 +164,7 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl // Auto-detect when no explicit proxy or domain is configured const hasExplicitProxyOrDomain = resolvedParams.proxyUrl || PROXY_URL || resolvedParams.domain || DOMAIN; - if (!frontendApiProxyConfig && !hasExplicitProxyOrDomain) { + if (!frontendApiProxyConfig && !hasExplicitProxyOrDomain && publishableKey.startsWith('pk_live_')) { const requestUrl = new URL(request.nextUrl.href); if (shouldAutoProxy(requestUrl.hostname)) { frontendApiProxyConfig = { enabled: true }; From b3e8273a087d2d91eca6c66dadc69a6466fa3c50 Mon Sep 17 00:00:00 2001 From: Railly Date: Thu, 16 Apr 2026 17:08:03 -0500 Subject: [PATCH 19/31] fix(nextjs): hoist requestUrl declaration to fix ReferenceError with function-based frontendApiProxy --- packages/nextjs/src/server/clerkMiddleware.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index ce5e95511ab..e011c284e66 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -160,12 +160,12 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl ); // Handle Frontend API proxy requests early, before authentication + const requestUrl = new URL(request.nextUrl.href); let frontendApiProxyConfig = resolvedParams.frontendApiProxy; // Auto-detect when no explicit proxy or domain is configured const hasExplicitProxyOrDomain = resolvedParams.proxyUrl || PROXY_URL || resolvedParams.domain || DOMAIN; if (!frontendApiProxyConfig && !hasExplicitProxyOrDomain && publishableKey.startsWith('pk_live_')) { - const requestUrl = new URL(request.nextUrl.href); if (shouldAutoProxy(requestUrl.hostname)) { frontendApiProxyConfig = { enabled: true }; } From 66a8ad1a6d567def6a8aea4f085f0b67e4095188 Mon Sep 17 00:00:00 2001 From: Railly Date: Fri, 17 Apr 2026 15:43:23 -0500 Subject: [PATCH 20/31] test(backend): fix auto-proxy tests to use pkLive, add dev key negative case --- .../__tests__/authenticateContext.test.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts index 643724cddbd..791eff21e7c 100644 --- a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts +++ b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts @@ -259,19 +259,28 @@ describe('AuthenticateContext', () => { }); describe('auto-proxy for eligible hosts', () => { - it('auto-derives proxyUrl for eligible hostnames', async () => { + it('auto-derives proxyUrl for production instances on eligible hostnames', async () => { const clerkRequest = createClerkRequest(new Request('https://myapp-abc123.vercel.app/dashboard')); const context = await createAuthenticateContext(clerkRequest, { - publishableKey: pkTest, + publishableKey: pkLive, }); expect(context.proxyUrl).toBe('https://myapp-abc123.vercel.app/__clerk'); }); + it('does NOT auto-derive proxyUrl for development instances on eligible hostnames', async () => { + const clerkRequest = createClerkRequest(new Request('https://myapp-abc123.vercel.app/dashboard')); + const context = await createAuthenticateContext(clerkRequest, { + publishableKey: pkTest, + }); + + expect(context.proxyUrl).toBeUndefined(); + }); + it('does NOT auto-derive proxyUrl for ineligible domains', async () => { const clerkRequest = createClerkRequest(new Request('https://myapp.com/dashboard')); const context = await createAuthenticateContext(clerkRequest, { - publishableKey: pkTest, + publishableKey: pkLive, }); expect(context.proxyUrl).toBeUndefined(); @@ -280,7 +289,7 @@ describe('AuthenticateContext', () => { it('explicit proxyUrl takes precedence over auto-detection', async () => { const clerkRequest = createClerkRequest(new Request('https://myapp-abc123.vercel.app/dashboard')); const context = await createAuthenticateContext(clerkRequest, { - publishableKey: pkTest, + publishableKey: pkLive, proxyUrl: 'https://custom-proxy.example.com/__clerk', }); @@ -290,7 +299,7 @@ describe('AuthenticateContext', () => { it('explicit domain skips auto-detection', async () => { const clerkRequest = createClerkRequest(new Request('https://myapp-abc123.vercel.app/dashboard')); const context = await createAuthenticateContext(clerkRequest, { - publishableKey: pkTest, + publishableKey: pkLive, domain: 'clerk.myapp.com', }); From cba7dfd29b03eaeac2e13867f797e6e7ecce0aab Mon Sep 17 00:00:00 2001 From: Railly Date: Fri, 17 Apr 2026 15:43:30 -0500 Subject: [PATCH 21/31] fix(backend): document X-Forwarded-Host trust assumption in auto-proxy --- packages/backend/src/tokens/authenticateContext.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index 914f85dce38..4022da8be83 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -1,4 +1,5 @@ import { buildAccountsBaseUrl } from '@clerk/shared/buildAccountsBaseUrl'; +import { isProductionFromPublishableKey } from '@clerk/shared/keys'; import { AUTO_PROXY_PATH, shouldAutoProxy } from '@clerk/shared/proxy'; import type { Jwt } from '@clerk/shared/types'; import { isCurrentDevAccountPortalOrigin, isLegacyDevAccountPortalOrigin } from '@clerk/shared/url'; @@ -71,8 +72,10 @@ class AuthenticateContext implements AuthenticateContext { private clerkRequest: ClerkRequest, options: AuthenticateRequestOptions, ) { - // Auto-detect proxy for supported platform deployments (production only) - if (!options.proxyUrl && !options.domain && options.publishableKey?.startsWith('pk_live_')) { + // Auto-detect proxy for supported platform deployments (production only). + // Note: hostname is derived from X-Forwarded-Host when present, which is + // authoritative on Vercel's edge but spoofable behind misconfigured proxies. + if (!options.proxyUrl && !options.domain && isProductionFromPublishableKey(options.publishableKey ?? '')) { const hostname = clerkRequest.clerkUrl.hostname; if (shouldAutoProxy(hostname)) { options = { ...options, proxyUrl: `${clerkRequest.clerkUrl.origin}${AUTO_PROXY_PATH}` }; From c9c28b0ef07472b8a7e04c02afc9cd0ca60be785 Mon Sep 17 00:00:00 2001 From: Railly Date: Fri, 17 Apr 2026 15:43:37 -0500 Subject: [PATCH 22/31] fix(shared): wrap normalizeHostname URL parsing in try-catch --- packages/shared/src/proxy.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/proxy.ts b/packages/shared/src/proxy.ts index 2b8b1310a90..8284af495e4 100644 --- a/packages/shared/src/proxy.ts +++ b/packages/shared/src/proxy.ts @@ -53,7 +53,11 @@ export function shouldAutoProxy(hostname: string): boolean { function normalizeHostname(hostnameOrUrl: string): string { if (hostnameOrUrl.startsWith('http://') || hostnameOrUrl.startsWith('https://')) { - return new URL(hostnameOrUrl).hostname; + try { + return new URL(hostnameOrUrl).hostname; + } catch { + return ''; + } } return hostnameOrUrl.split('/')[0] || ''; From 2371ab22f420816d70263b883a8c94b73a034511 Mon Sep 17 00:00:00 2001 From: Railly Date: Fri, 17 Apr 2026 15:43:47 -0500 Subject: [PATCH 23/31] refactor: use isProductionFromPublishableKey instead of raw pk_live_ prefix check --- packages/nextjs/src/server/clerkMiddleware.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index e011c284e66..6da9e745cd0 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -21,7 +21,7 @@ import { TokenType, } from '@clerk/backend/internal'; import { clerkFrontendApiProxy, DEFAULT_PROXY_PATH, matchProxyPath } from '@clerk/backend/proxy'; -import { parsePublishableKey } from '@clerk/shared/keys'; +import { isProductionFromPublishableKey, parsePublishableKey } from '@clerk/shared/keys'; import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler'; import { shouldAutoProxy } from '@clerk/shared/proxy'; import { notFound as nextjsNotFound } from 'next/navigation'; @@ -165,7 +165,7 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl // Auto-detect when no explicit proxy or domain is configured const hasExplicitProxyOrDomain = resolvedParams.proxyUrl || PROXY_URL || resolvedParams.domain || DOMAIN; - if (!frontendApiProxyConfig && !hasExplicitProxyOrDomain && publishableKey.startsWith('pk_live_')) { + if (!frontendApiProxyConfig && !hasExplicitProxyOrDomain && isProductionFromPublishableKey(publishableKey)) { if (shouldAutoProxy(requestUrl.hostname)) { frontendApiProxyConfig = { enabled: true }; } From 670d2af47a62a5d8a33887b1d45b7119aff32590 Mon Sep 17 00:00:00 2001 From: Railly Date: Fri, 17 Apr 2026 16:32:48 -0500 Subject: [PATCH 24/31] fix(nextjs): use production key in middleware auto-proxy tests, restore keyless-provider formatting --- .../nextjs/src/server/__tests__/clerkMiddleware.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts index 9b56e0c92da..02fe029f32c 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -1226,13 +1226,15 @@ describe('frontendApiProxy multi-domain support', () => { }); describe('auto-proxy for eligible hosts', () => { + const productionPublishableKey = 'pk_live_Y2xlcmsuaW5jbHVkZWQua2F0eWRpZC05Mi5sY2wuZGV2JA'; + it('auto-intercepts /__clerk/* requests on eligible hostnames', async () => { const req = new NextRequest(new URL('/__clerk/v1/client', 'https://myapp-abc123.vercel.app').toString(), { method: 'GET', headers: new Headers(), }); - const resp = await clerkMiddleware()(req, {} as NextFetchEvent); + const resp = await clerkMiddleware({ publishableKey: productionPublishableKey })(req, {} as NextFetchEvent); // Proxy should intercept the request — authenticateRequest should NOT be called expect((await clerkClient()).authenticateRequest).not.toBeCalled(); @@ -1250,7 +1252,7 @@ describe('auto-proxy for eligible hosts', () => { configurable: true, }); - const resp = await clerkMiddleware()(req, {} as NextFetchEvent); + const resp = await clerkMiddleware({ publishableKey: productionPublishableKey })(req, {} as NextFetchEvent); expect((await clerkClient()).authenticateRequest).not.toBeCalled(); expect(resp?.status).toBeDefined(); From 002493235a74a186439ddaf5e8d5fc76eeff9dc1 Mon Sep 17 00:00:00 2001 From: Railly Date: Fri, 17 Apr 2026 16:40:51 -0500 Subject: [PATCH 25/31] style: restore keyless-provider.tsx formatting from main --- packages/nextjs/src/app-router/server/keyless-provider.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/nextjs/src/app-router/server/keyless-provider.tsx b/packages/nextjs/src/app-router/server/keyless-provider.tsx index fe3c1b9777b..0eb7a87405c 100644 --- a/packages/nextjs/src/app-router/server/keyless-provider.tsx +++ b/packages/nextjs/src/app-router/server/keyless-provider.tsx @@ -43,9 +43,8 @@ export const KeylessProvider = async (props: KeylessProviderProps) => { .then(mod => mod.keyless().getOrCreateKeys()) .catch(() => null); - const { clerkDevelopmentCache, createConfirmationMessage, createKeylessModeMessage } = await import( - '../../server/keyless-log-cache.js' - ); + const { clerkDevelopmentCache, createConfirmationMessage, createKeylessModeMessage } = + await import('../../server/keyless-log-cache.js'); if (!newOrReadKeys) { // When case keyless should run, but keys are not available, then fallback to throwing for missing keys From 174ed67f2bd520d542cc59ee847742bc7424d7ee Mon Sep 17 00:00:00 2001 From: Railly Date: Mon, 20 Apr 2026 11:00:12 -0500 Subject: [PATCH 26/31] style: restore UserSettings.ts formatting from main --- packages/clerk-js/src/core/resources/UserSettings.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/src/core/resources/UserSettings.ts b/packages/clerk-js/src/core/resources/UserSettings.ts index 93078997ebc..48a8c85a426 100644 --- a/packages/clerk-js/src/core/resources/UserSettings.ts +++ b/packages/clerk-js/src/core/resources/UserSettings.ts @@ -196,8 +196,8 @@ export class UserSettings extends BaseResource implements UserSettingsResource { get hasValidAuthFactor() { return Boolean( this.attributes?.email_address?.enabled || - this.attributes?.phone_number?.enabled || - (this.attributes.password?.required && this.attributes.username?.required), + this.attributes?.phone_number?.enabled || + (this.attributes.password?.required && this.attributes.username?.required), ); } From 06bce1d51efe9c01e1994fd9beb3d006ee4d36cb Mon Sep 17 00:00:00 2001 From: Railly Date: Mon, 20 Apr 2026 11:04:36 -0500 Subject: [PATCH 27/31] style: restore merge artifact formatting from main --- .../hooks/createBillingPaginatedHook.tsx | 41 +++++++++---------- packages/shared/src/types/localization.ts | 5 ++- test-signin-provider/next-env.d.ts | 6 +++ 3 files changed, 29 insertions(+), 23 deletions(-) create mode 100644 test-signin-provider/next-env.d.ts diff --git a/packages/shared/src/react/hooks/createBillingPaginatedHook.tsx b/packages/shared/src/react/hooks/createBillingPaginatedHook.tsx index d41bdc57d78..8b057f0309a 100644 --- a/packages/shared/src/react/hooks/createBillingPaginatedHook.tsx +++ b/packages/shared/src/react/hooks/createBillingPaginatedHook.tsx @@ -26,27 +26,26 @@ type BillingHookConfig { +export interface HookParams extends PaginatedHookConfig< + PagesOrInfiniteOptions & { + /** + * If `true`, a request will be triggered when the hook is mounted. + * + * @default true + */ + enabled?: boolean; + /** + * On `cache` mode, no request will be triggered when the hook is mounted and the data will be fetched from the cache. + * + * @default undefined + * + * @hidden + * + * @experimental + */ + __experimental_mode?: 'cache'; + } +> { /** * Specifies whether to fetch for the current user or Organization. * diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 094ac9cdb5b..222509565bb 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -62,8 +62,9 @@ type DeepLocalizationWithoutObjects = { * as a starting point. */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- Needs to be an interface for typedoc to link correctly -export interface LocalizationResource - extends DeepPartial> {} +export interface LocalizationResource extends DeepPartial< + DeepLocalizationWithoutObjects<__internal_LocalizationResource> +> {} export type __internal_LocalizationResource = { locale: string; diff --git a/test-signin-provider/next-env.d.ts b/test-signin-provider/next-env.d.ts new file mode 100644 index 00000000000..c4b7818fbb2 --- /dev/null +++ b/test-signin-provider/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/dev/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From 4d7c662c8560f286b2f5c8599c75a7f571e9acba Mon Sep 17 00:00:00 2001 From: Railly Date: Mon, 20 Apr 2026 11:04:53 -0500 Subject: [PATCH 28/31] chore: remove stale test-signin-provider file --- test-signin-provider/next-env.d.ts | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 test-signin-provider/next-env.d.ts diff --git a/test-signin-provider/next-env.d.ts b/test-signin-provider/next-env.d.ts deleted file mode 100644 index c4b7818fbb2..00000000000 --- a/test-signin-provider/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -import "./.next/dev/types/routes.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From e0a275dc9b155ac9ac34e22fd3a0b34052eaf3e6 Mon Sep 17 00:00:00 2001 From: Railly Date: Wed, 22 Apr 2026 14:07:02 -0500 Subject: [PATCH 29/31] docs(shared): add comment explaining build-time/runtime dual execution of getAutoProxyUrlFromEnvironment --- packages/shared/src/proxy.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/shared/src/proxy.ts b/packages/shared/src/proxy.ts index 8284af495e4..6413accf405 100644 --- a/packages/shared/src/proxy.ts +++ b/packages/shared/src/proxy.ts @@ -70,6 +70,12 @@ type GetAutoProxyUrlFromEnvironmentOptions = { environment?: NodeJS.ProcessEnv; }; +/** + * Determines if the current Vercel environment should use auto-proxy. + * Note: This runs both at build time (static generation) and at runtime + * (server-side rendering) via mergeNextClerkPropsWithEnv in providers. + * The return value may become the proxyUrl or the script src prefix. + */ export function getAutoProxyUrlFromEnvironment({ publishableKey, hasDomain = false, From 43be86fb2407b8991503f5be90ae7013f3aa59fc Mon Sep 17 00:00:00 2001 From: Railly Date: Wed, 22 Apr 2026 14:10:19 -0500 Subject: [PATCH 30/31] refactor(backend): use getAutoProxyUrlFromEnvironment instead of header-based detection --- .../__tests__/authenticateContext.test.ts | 24 +++++++++++++++---- .../backend/src/tokens/authenticateContext.ts | 21 ++++++++-------- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts index 791eff21e7c..064d5e960c7 100644 --- a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts +++ b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts @@ -259,7 +259,21 @@ describe('AuthenticateContext', () => { }); describe('auto-proxy for eligible hosts', () => { - it('auto-derives proxyUrl for production instances on eligible hostnames', async () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { + ...originalEnv, + VERCEL_TARGET_ENV: 'production', + VERCEL_PROJECT_PRODUCTION_URL: 'myapp-abc123.vercel.app', + }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('auto-derives proxyUrl when Vercel env vars indicate production vercel.app', async () => { const clerkRequest = createClerkRequest(new Request('https://myapp-abc123.vercel.app/dashboard')); const context = await createAuthenticateContext(clerkRequest, { publishableKey: pkLive, @@ -268,7 +282,7 @@ describe('AuthenticateContext', () => { expect(context.proxyUrl).toBe('https://myapp-abc123.vercel.app/__clerk'); }); - it('does NOT auto-derive proxyUrl for development instances on eligible hostnames', async () => { + it('does NOT auto-derive proxyUrl for development keys', async () => { const clerkRequest = createClerkRequest(new Request('https://myapp-abc123.vercel.app/dashboard')); const context = await createAuthenticateContext(clerkRequest, { publishableKey: pkTest, @@ -277,8 +291,10 @@ describe('AuthenticateContext', () => { expect(context.proxyUrl).toBeUndefined(); }); - it('does NOT auto-derive proxyUrl for ineligible domains', async () => { - const clerkRequest = createClerkRequest(new Request('https://myapp.com/dashboard')); + it('does NOT auto-derive proxyUrl when Vercel env vars are absent', async () => { + delete process.env.VERCEL_TARGET_ENV; + delete process.env.VERCEL_PROJECT_PRODUCTION_URL; + const clerkRequest = createClerkRequest(new Request('https://myapp-abc123.vercel.app/dashboard')); const context = await createAuthenticateContext(clerkRequest, { publishableKey: pkLive, }); diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index 4022da8be83..a29cbcaaf43 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -1,6 +1,5 @@ import { buildAccountsBaseUrl } from '@clerk/shared/buildAccountsBaseUrl'; -import { isProductionFromPublishableKey } from '@clerk/shared/keys'; -import { AUTO_PROXY_PATH, shouldAutoProxy } from '@clerk/shared/proxy'; +import { AUTO_PROXY_PATH, getAutoProxyUrlFromEnvironment } from '@clerk/shared/proxy'; import type { Jwt } from '@clerk/shared/types'; import { isCurrentDevAccountPortalOrigin, isLegacyDevAccountPortalOrigin } from '@clerk/shared/url'; @@ -72,14 +71,16 @@ class AuthenticateContext implements AuthenticateContext { private clerkRequest: ClerkRequest, options: AuthenticateRequestOptions, ) { - // Auto-detect proxy for supported platform deployments (production only). - // Note: hostname is derived from X-Forwarded-Host when present, which is - // authoritative on Vercel's edge but spoofable behind misconfigured proxies. - if (!options.proxyUrl && !options.domain && isProductionFromPublishableKey(options.publishableKey ?? '')) { - const hostname = clerkRequest.clerkUrl.hostname; - if (shouldAutoProxy(hostname)) { - options = { ...options, proxyUrl: `${clerkRequest.clerkUrl.origin}${AUTO_PROXY_PATH}` }; - } + // Auto-detect proxy for supported platform deployments using environment + // variables (e.g. VERCEL_TARGET_ENV, VERCEL_PROJECT_PRODUCTION_URL) instead + // of request headers, which avoids X-Forwarded-Host spoofing concerns. + const autoProxyPath = getAutoProxyUrlFromEnvironment({ + publishableKey: options.publishableKey ?? '', + hasProxyUrl: !!options.proxyUrl, + hasDomain: !!options.domain, + }); + if (autoProxyPath) { + options = { ...options, proxyUrl: `${clerkRequest.clerkUrl.origin}${autoProxyPath}` }; } if (options.acceptsToken === TokenType.M2MToken || options.acceptsToken === TokenType.ApiKey) { From d7feb1c542e4a2d3378f5178b07f16055b2c891a Mon Sep 17 00:00:00 2001 From: Railly Date: Mon, 27 Apr 2026 12:30:21 -0500 Subject: [PATCH 31/31] fix(backend): remove unused AUTO_PROXY_PATH import --- packages/backend/src/tokens/authenticateContext.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index a29cbcaaf43..794c9268874 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -1,5 +1,5 @@ import { buildAccountsBaseUrl } from '@clerk/shared/buildAccountsBaseUrl'; -import { AUTO_PROXY_PATH, getAutoProxyUrlFromEnvironment } from '@clerk/shared/proxy'; +import { getAutoProxyUrlFromEnvironment } from '@clerk/shared/proxy'; import type { Jwt } from '@clerk/shared/types'; import { isCurrentDevAccountPortalOrigin, isLegacyDevAccountPortalOrigin } from '@clerk/shared/url';