Skip to content

Commit 9b57986

Browse files
authored
feat(*): auto-proxy for eligible hosts (#8035)
1 parent 115cf98 commit 9b57986

15 files changed

Lines changed: 588 additions & 15 deletions

File tree

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

packages/backend/src/tokens/__tests__/authenticateContext.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,71 @@ describe('AuthenticateContext', () => {
258258
});
259259
});
260260

261+
describe('auto-proxy for eligible hosts', () => {
262+
const originalEnv = process.env;
263+
264+
beforeEach(() => {
265+
process.env = {
266+
...originalEnv,
267+
VERCEL_TARGET_ENV: 'production',
268+
VERCEL_PROJECT_PRODUCTION_URL: 'myapp-abc123.vercel.app',
269+
};
270+
});
271+
272+
afterEach(() => {
273+
process.env = originalEnv;
274+
});
275+
276+
it('auto-derives proxyUrl when Vercel env vars indicate production vercel.app', async () => {
277+
const clerkRequest = createClerkRequest(new Request('https://myapp-abc123.vercel.app/dashboard'));
278+
const context = await createAuthenticateContext(clerkRequest, {
279+
publishableKey: pkLive,
280+
});
281+
282+
expect(context.proxyUrl).toBe('https://myapp-abc123.vercel.app/__clerk');
283+
});
284+
285+
it('does NOT auto-derive proxyUrl for development keys', async () => {
286+
const clerkRequest = createClerkRequest(new Request('https://myapp-abc123.vercel.app/dashboard'));
287+
const context = await createAuthenticateContext(clerkRequest, {
288+
publishableKey: pkTest,
289+
});
290+
291+
expect(context.proxyUrl).toBeUndefined();
292+
});
293+
294+
it('does NOT auto-derive proxyUrl when Vercel env vars are absent', async () => {
295+
delete process.env.VERCEL_TARGET_ENV;
296+
delete process.env.VERCEL_PROJECT_PRODUCTION_URL;
297+
const clerkRequest = createClerkRequest(new Request('https://myapp-abc123.vercel.app/dashboard'));
298+
const context = await createAuthenticateContext(clerkRequest, {
299+
publishableKey: pkLive,
300+
});
301+
302+
expect(context.proxyUrl).toBeUndefined();
303+
});
304+
305+
it('explicit proxyUrl takes precedence over auto-detection', async () => {
306+
const clerkRequest = createClerkRequest(new Request('https://myapp-abc123.vercel.app/dashboard'));
307+
const context = await createAuthenticateContext(clerkRequest, {
308+
publishableKey: pkLive,
309+
proxyUrl: 'https://custom-proxy.example.com/__clerk',
310+
});
311+
312+
expect(context.proxyUrl).toBe('https://custom-proxy.example.com/__clerk');
313+
});
314+
315+
it('explicit domain skips auto-detection', async () => {
316+
const clerkRequest = createClerkRequest(new Request('https://myapp-abc123.vercel.app/dashboard'));
317+
const context = await createAuthenticateContext(clerkRequest, {
318+
publishableKey: pkLive,
319+
domain: 'clerk.myapp.com',
320+
});
321+
322+
expect(context.proxyUrl).toBeUndefined();
323+
});
324+
});
325+
261326
// Added these tests to verify that the generated sha-1 is the same as the one used in cookie assignment
262327
// Tests copied from packages/shared/src/__tests__/keys.test.ts
263328
describe('getCookieSuffix(publishableKey, subtle)', () => {

packages/backend/src/tokens/authenticateContext.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { buildAccountsBaseUrl } from '@clerk/shared/buildAccountsBaseUrl';
2+
import { getAutoProxyUrlFromEnvironment } from '@clerk/shared/proxy';
23
import type { Jwt } from '@clerk/shared/types';
34
import { isCurrentDevAccountPortalOrigin, isLegacyDevAccountPortalOrigin } from '@clerk/shared/url';
45

@@ -70,6 +71,18 @@ class AuthenticateContext implements AuthenticateContext {
7071
private clerkRequest: ClerkRequest,
7172
options: AuthenticateRequestOptions,
7273
) {
74+
// Auto-detect proxy for supported platform deployments using environment
75+
// variables (e.g. VERCEL_TARGET_ENV, VERCEL_PROJECT_PRODUCTION_URL) instead
76+
// of request headers, which avoids X-Forwarded-Host spoofing concerns.
77+
const autoProxyPath = getAutoProxyUrlFromEnvironment({
78+
publishableKey: options.publishableKey ?? '',
79+
hasProxyUrl: !!options.proxyUrl,
80+
hasDomain: !!options.domain,
81+
});
82+
if (autoProxyPath) {
83+
options = { ...options, proxyUrl: `${clerkRequest.clerkUrl.origin}${autoProxyPath}` };
84+
}
85+
7386
if (options.acceptsToken === TokenType.M2MToken || options.acceptsToken === TokenType.ApiKey) {
7487
// For non-session tokens, we only want to set the header values.
7588
this.initHeaderValues();

packages/clerk-js/src/core/__tests__/clerk.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2516,6 +2516,86 @@ describe('Clerk singleton', () => {
25162516
});
25172517
});
25182518
});
2519+
2520+
describe('auto-detection for eligible hosts', () => {
2521+
const originalLocation = window.location;
2522+
2523+
afterEach(() => {
2524+
Object.defineProperty(window, 'location', {
2525+
value: originalLocation,
2526+
writable: true,
2527+
});
2528+
});
2529+
2530+
test('auto-derives proxyUrl for production instances on eligible hosts', () => {
2531+
Object.defineProperty(window, 'location', {
2532+
value: {
2533+
...originalLocation,
2534+
hostname: 'myapp-abc123.vercel.app',
2535+
origin: 'https://myapp-abc123.vercel.app',
2536+
href: 'https://myapp-abc123.vercel.app/dashboard',
2537+
},
2538+
writable: true,
2539+
});
2540+
2541+
const sut = new Clerk(productionPublishableKey);
2542+
expect(sut.proxyUrl).toBe('https://myapp-abc123.vercel.app/__clerk');
2543+
});
2544+
2545+
test('does NOT auto-derive proxyUrl for development instances on eligible hosts', () => {
2546+
Object.defineProperty(window, 'location', {
2547+
value: {
2548+
...originalLocation,
2549+
hostname: 'myapp-abc123.vercel.app',
2550+
origin: 'https://myapp-abc123.vercel.app',
2551+
href: 'https://myapp-abc123.vercel.app/dashboard',
2552+
},
2553+
writable: true,
2554+
});
2555+
2556+
const sut = new Clerk(developmentPublishableKey);
2557+
expect(sut.proxyUrl).toBe('');
2558+
});
2559+
2560+
test('does NOT auto-derive proxyUrl for ineligible domains', () => {
2561+
const sut = new Clerk(productionPublishableKey);
2562+
expect(sut.proxyUrl).toBe('');
2563+
});
2564+
2565+
test('explicit proxyUrl takes precedence over auto-detection', () => {
2566+
Object.defineProperty(window, 'location', {
2567+
value: {
2568+
...originalLocation,
2569+
hostname: 'myapp-abc123.vercel.app',
2570+
origin: 'https://myapp-abc123.vercel.app',
2571+
href: 'https://myapp-abc123.vercel.app/dashboard',
2572+
},
2573+
writable: true,
2574+
});
2575+
2576+
const sut = new Clerk(productionPublishableKey, {
2577+
proxyUrl: 'https://custom-proxy.example.com/__clerk',
2578+
});
2579+
expect(sut.proxyUrl).toBe('https://custom-proxy.example.com/__clerk');
2580+
});
2581+
2582+
test('explicit domain skips auto-detection', () => {
2583+
Object.defineProperty(window, 'location', {
2584+
value: {
2585+
...originalLocation,
2586+
hostname: 'myapp-abc123.vercel.app',
2587+
origin: 'https://myapp-abc123.vercel.app',
2588+
href: 'https://myapp-abc123.vercel.app/dashboard',
2589+
},
2590+
writable: true,
2591+
});
2592+
2593+
const sut = new Clerk(productionPublishableKey, {
2594+
domain: 'clerk.myapp.com',
2595+
});
2596+
expect(sut.proxyUrl).toBe('');
2597+
});
2598+
});
25192599
});
25202600

25212601
describe('buildUrlWithAuth', () => {

packages/clerk-js/src/core/clerk.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,13 @@ import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
3838
import { parsePublishableKey } from '@clerk/shared/keys';
3939
import { logger } from '@clerk/shared/logger';
4040
import { CLERK_NETLIFY_CACHE_BUST_PARAM } from '@clerk/shared/netlifyCacheHandler';
41-
import { isHttpOrHttps, isValidProxyUrl, proxyUrlToAbsoluteURL } from '@clerk/shared/proxy';
41+
import {
42+
AUTO_PROXY_PATH,
43+
isHttpOrHttps,
44+
isValidProxyUrl,
45+
proxyUrlToAbsoluteURL,
46+
shouldAutoProxy,
47+
} from '@clerk/shared/proxy';
4248
import {
4349
eventPrebuiltComponentMounted,
4450
eventPrebuiltComponentOpened,
@@ -361,7 +367,14 @@ export class Clerk implements ClerkInterface {
361367
if (!isValidProxyUrl(_unfilteredProxy)) {
362368
errorThrower.throwInvalidProxyUrl({ url: _unfilteredProxy });
363369
}
364-
return proxyUrlToAbsoluteURL(_unfilteredProxy);
370+
const resolved = proxyUrlToAbsoluteURL(_unfilteredProxy);
371+
if (resolved) {
372+
return resolved;
373+
}
374+
// Auto-detect when no explicit proxy or domain is configured (production only)
375+
if (!this.#domain && this.#instanceType === 'production' && shouldAutoProxy(window.location.hostname)) {
376+
return `${window.location.origin}${AUTO_PROXY_PATH}`;
377+
}
365378
}
366379

367380
if (typeof this.#proxyUrl === 'function') {

packages/nextjs/src/app-router/server/__tests__/DynamicClerkScripts.test.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,4 +86,24 @@ describe('DynamicClerkScripts', () => {
8686
expect(html).not.toContain('nonce="test');
8787
expect(html).not.toContain('nonce="csp');
8888
});
89+
90+
it('renders initial script tags with relative proxied asset URLs', async () => {
91+
mockHeaders.mockResolvedValue(
92+
new Map([
93+
['X-Nonce', null],
94+
['Content-Security-Policy', ''],
95+
]),
96+
);
97+
98+
const html = await render(
99+
DynamicClerkScripts({
100+
...defaultProps,
101+
proxyUrl: '/__clerk',
102+
}),
103+
);
104+
105+
expect(html).toContain('src="/__clerk/npm/@clerk/clerk-js@');
106+
expect(html).toContain('href="/__clerk/npm/@clerk/ui@');
107+
expect(html).toContain('data-clerk-proxy-url="/__clerk"');
108+
});
89109
});

packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1326,6 +1326,40 @@ describe('frontendApiProxy multi-domain support', () => {
13261326
});
13271327
});
13281328

1329+
describe('auto-proxy for eligible hosts', () => {
1330+
const productionPublishableKey = 'pk_live_Y2xlcmsuaW5jbHVkZWQua2F0eWRpZC05Mi5sY2wuZGV2JA';
1331+
1332+
it('auto-intercepts /__clerk/* requests on eligible hostnames', async () => {
1333+
const req = new NextRequest(new URL('/__clerk/v1/client', 'https://myapp-abc123.vercel.app').toString(), {
1334+
method: 'GET',
1335+
headers: new Headers(),
1336+
});
1337+
1338+
const resp = await clerkMiddleware({ publishableKey: productionPublishableKey })(req, {} as NextFetchEvent);
1339+
1340+
// Proxy should intercept the request — authenticateRequest should NOT be called
1341+
expect((await clerkClient()).authenticateRequest).not.toBeCalled();
1342+
expect(resp?.status).toBeDefined();
1343+
});
1344+
1345+
it('uses request.nextUrl for auto-detection', async () => {
1346+
const req = new NextRequest('http://127.0.0.1:3000/__clerk/v1/client', {
1347+
method: 'GET',
1348+
headers: new Headers(),
1349+
});
1350+
1351+
Object.defineProperty(req, 'nextUrl', {
1352+
value: new URL('https://myapp-abc123.vercel.app/__clerk/v1/client'),
1353+
configurable: true,
1354+
});
1355+
1356+
const resp = await clerkMiddleware({ publishableKey: productionPublishableKey })(req, {} as NextFetchEvent);
1357+
1358+
expect((await clerkClient()).authenticateRequest).not.toBeCalled();
1359+
expect(resp?.status).toBeDefined();
1360+
});
1361+
});
1362+
13291363
describe('contentSecurityPolicy option', () => {
13301364
it('forwards CSP headers as request headers when strict mode is enabled', async () => {
13311365
const resp = await clerkMiddleware({

packages/nextjs/src/server/clerkMiddleware.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,10 @@ import {
2121
TokenType,
2222
} from '@clerk/backend/internal';
2323
import { clerkFrontendApiProxy, DEFAULT_PROXY_PATH, matchProxyPath } from '@clerk/backend/proxy';
24-
import { parsePublishableKey } from '@clerk/shared/keys';
24+
import { isProductionFromPublishableKey, parsePublishableKey } from '@clerk/shared/keys';
2525
import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler';
2626
import { isMalformedURLError } from '@clerk/shared/pathMatcher';
27+
import { shouldAutoProxy } from '@clerk/shared/proxy';
2728
import { notFound as nextjsNotFound } from 'next/navigation';
2829
import type { NextMiddleware, NextRequest } from 'next/server';
2930
import { NextResponse } from 'next/server';
@@ -35,7 +36,7 @@ import type { Logger, LoggerNoCommit } from '../utils/debugLogger';
3536
import { withLogger } from '../utils/debugLogger';
3637
import { canUseKeyless } from '../utils/feature-flags';
3738
import { clerkClient } from './clerkClient';
38-
import { PUBLISHABLE_KEY, SECRET_KEY, SIGN_IN_URL, SIGN_UP_URL } from './constants';
39+
import { DOMAIN, PROXY_URL, PUBLISHABLE_KEY, SECRET_KEY, SIGN_IN_URL, SIGN_UP_URL } from './constants';
3940
import { type ContentSecurityPolicyOptions, createContentSecurityPolicyHeaders } from './content-security-policy';
4041
import { errorThrower } from './errorThrower';
4142
import { getHeader } from './headers-utils';
@@ -161,12 +162,20 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl
161162
);
162163

163164
// Handle Frontend API proxy requests early, before authentication
164-
const frontendApiProxyConfig = resolvedParams.frontendApiProxy;
165+
const requestUrl = new URL(request.nextUrl.href);
166+
let frontendApiProxyConfig = resolvedParams.frontendApiProxy;
167+
168+
// Auto-detect when no explicit proxy or domain is configured
169+
const hasExplicitProxyOrDomain = resolvedParams.proxyUrl || PROXY_URL || resolvedParams.domain || DOMAIN;
170+
if (!frontendApiProxyConfig && !hasExplicitProxyOrDomain && isProductionFromPublishableKey(publishableKey)) {
171+
if (shouldAutoProxy(requestUrl.hostname)) {
172+
frontendApiProxyConfig = { enabled: true };
173+
}
174+
}
165175
if (frontendApiProxyConfig) {
166176
const { enabled, path: proxyPath = DEFAULT_PROXY_PATH } = frontendApiProxyConfig;
167177

168178
// Resolve enabled - either boolean or function
169-
const requestUrl = new URL(request.url);
170179
const isEnabled = typeof enabled === 'function' ? enabled(requestUrl) : enabled;
171180

172181
if (isEnabled && matchProxyPath(request, { proxyPath })) {

0 commit comments

Comments
 (0)