Skip to content

Commit 083c4c5

Browse files
authored
chore(express,react,shared): Support dynamic options callback in clerkMiddleware (#8398)
1 parent 4b62ce8 commit 083c4c5

9 files changed

Lines changed: 175 additions & 9 deletions

File tree

.changeset/brave-lions-fly.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@clerk/shared": patch
3+
"@clerk/react": patch
4+
---
5+
6+
Add `publishableKeyFromHost` utility for resolving the correct publishable key per hostname in multi-domain setups. Re-exported from `@clerk/react/internal`.

.changeset/new-kangaroos-search.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clerk/express": patch
3+
---
4+
5+
Support dynamic options callback in `clerkMiddleware` for multi-domain and multi-tenant setups.

packages/express/src/__tests__/clerkMiddleware.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,54 @@ describe('clerkMiddleware', () => {
245245
});
246246
});
247247

248+
describe('with options callback', () => {
249+
it('accepts a callback function and resolves options per request', async () => {
250+
const optionsCallback = vi.fn().mockResolvedValue({
251+
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
252+
secretKey: 'sk_test_....',
253+
});
254+
255+
const response = await runMiddleware(clerkMiddleware(optionsCallback), {
256+
Cookie: '__clerk_db_jwt=deadbeef;',
257+
}).expect(200, 'Hello world!');
258+
259+
expect(optionsCallback).toHaveBeenCalledOnce();
260+
assertSignedOutDebugHeaders(response);
261+
});
262+
263+
it('calls the callback with the incoming request', async () => {
264+
let capturedHostname: string | undefined;
265+
266+
const optionsCallback = vi.fn().mockImplementation((req: Request) => {
267+
capturedHostname = req.hostname;
268+
return Promise.resolve({
269+
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
270+
secretKey: 'sk_test_....',
271+
});
272+
});
273+
274+
await runMiddleware(clerkMiddleware(optionsCallback), {
275+
Cookie: '__clerk_db_jwt=deadbeef;',
276+
Host: 'example.com',
277+
}).expect(200, 'Hello world!');
278+
279+
expect(capturedHostname).toBe('example.com');
280+
});
281+
282+
it('accepts a synchronous callback (non-Promise return)', async () => {
283+
const optionsCallback = vi.fn().mockReturnValue({
284+
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
285+
secretKey: 'sk_test_....',
286+
});
287+
288+
const response = await runMiddleware(clerkMiddleware(optionsCallback), {
289+
Cookie: '__clerk_db_jwt=deadbeef;',
290+
}).expect(200, 'Hello world!');
291+
292+
assertSignedOutDebugHeaders(response);
293+
});
294+
});
295+
248296
it('calls next with an error when request URL is invalid', () => {
249297
const req = {
250298
url: '//',
Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import type { RequestHandler } from 'express';
22

33
import { authenticateAndDecorateRequest } from './authenticateRequest';
4-
import type { ClerkMiddlewareOptions } from './types';
4+
import type { ClerkMiddlewareOptions, ClerkMiddlewareOptionsCallback } from './types';
55

66
/**
77
* Middleware that integrates Clerk authentication into your Express application.
88
* It checks the request's cookies and headers for a session JWT and, if found,
99
* attaches the Auth object to the request object under the `auth` key.
1010
*
11+
* Accepts either a static options object or a callback that receives the request
12+
* and returns options. The callback form is useful for multi-domain setups where
13+
* the publishable key differs per domain.
14+
*
1115
* @example
1216
* app.use(clerkMiddleware(options));
1317
*
@@ -17,14 +21,36 @@ import type { ClerkMiddlewareOptions } from './types';
1721
*
1822
* @example
1923
* app.use(clerkMiddleware());
24+
*
25+
* @example
26+
* // Dynamic keys per domain
27+
* app.use(clerkMiddleware((req) => ({
28+
* publishableKey: req.hostname === 'example.com' ? PK_A : PK_B,
29+
* })));
2030
*/
21-
export const clerkMiddleware = (options: ClerkMiddlewareOptions = {}): RequestHandler => {
22-
const authMiddleware = authenticateAndDecorateRequest({
23-
...options,
24-
acceptsToken: 'any',
25-
});
31+
export const clerkMiddleware = (
32+
options: ClerkMiddlewareOptions | ClerkMiddlewareOptionsCallback = {},
33+
): RequestHandler => {
34+
if (typeof options !== 'function') {
35+
const authMiddleware = authenticateAndDecorateRequest({
36+
...options,
37+
acceptsToken: 'any',
38+
});
39+
return (request, response, next) => {
40+
authMiddleware(request, response, next);
41+
};
42+
}
2643

27-
return (request, response, next) => {
28-
authMiddleware(request, response, next);
44+
return async (request, response, next) => {
45+
try {
46+
const resolvedOptions = await options(request);
47+
const handler = authenticateAndDecorateRequest({
48+
...resolvedOptions,
49+
acceptsToken: 'any',
50+
});
51+
handler(request, response, next);
52+
} catch (err) {
53+
next(err);
54+
}
2955
};
3056
};

packages/express/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ export * from '@clerk/backend';
22

33
export { clerkClient } from './clerkClient';
44

5-
export type { ExpressRequestWithAuth } from './types';
5+
export type { ClerkMiddlewareOptions, ClerkMiddlewareOptionsCallback, ExpressRequestWithAuth } from './types';
66
export { clerkMiddleware } from './clerkMiddleware';
77
export { getAuth } from './getAuth';
88
export { requireAuth } from './requireAuth';

packages/express/src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ export interface FrontendApiProxyOptions {
2727
path?: string;
2828
}
2929

30+
export type ClerkMiddlewareOptionsCallback = (
31+
req: ExpressRequest,
32+
) => ClerkMiddlewareOptions | Promise<ClerkMiddlewareOptions>;
33+
3034
export type ClerkMiddlewareOptions = AuthenticateRequestOptions & {
3135
debug?: boolean;
3236
clerkClient?: ClerkClient;

packages/react/src/internal.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type React from 'react';
55
import { ClerkProvider } from './contexts/ClerkProvider';
66
import type { ClerkProviderProps } from './types';
77

8+
export { publishableKeyFromHost } from '@clerk/shared/keys';
89
export { setErrorThrowerOptions } from './errors/errorThrower';
910
export { MultisessionAppSupport } from './components/controlComponents';
1011
export { useOAuthConsent } from '@clerk/shared/react';

packages/shared/src/__tests__/keys.spec.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
isProductionFromSecretKey,
1111
isPublishableKey,
1212
parsePublishableKey,
13+
publishableKeyFromHost,
1314
} from '../keys';
1415

1516
describe('buildPublishableKey(frontendApi)', () => {
@@ -245,6 +246,46 @@ describe('isProductionFromSecretKey(key)', () => {
245246
});
246247
});
247248

249+
describe('publishableKeyFromHost(host, fallbackKey?)', () => {
250+
it('derives a pk_live_ key from a production hostname', () => {
251+
const result = publishableKeyFromHost('example.com');
252+
expect(result).toMatch(/^pk_live_/);
253+
expect(result).toBe(buildPublishableKey('clerk.example.com'));
254+
});
255+
256+
it('lowercases the host before deriving', () => {
257+
expect(publishableKeyFromHost('Example.COM')).toBe(publishableKeyFromHost('example.com'));
258+
});
259+
260+
it('returns the fallbackKey as-is when it is a development key', () => {
261+
const devKey = 'pk_test_Zm9vLWJhci0xMy5jbGVyay5hY2NvdW50cy5kZXYk';
262+
expect(publishableKeyFromHost('localhost', devKey)).toBe(devKey);
263+
});
264+
265+
it('derives from host when fallbackKey is a production key', () => {
266+
const prodKey = 'pk_live_ZmFrZS1jbGVyay10ZXN0LmNsZXJrLmFjY291bnRzLmRldiQ=';
267+
const result = publishableKeyFromHost('custom-domain.com', prodKey);
268+
expect(result).toMatch(/^pk_live_/);
269+
expect(result).toBe(buildPublishableKey('clerk.custom-domain.com'));
270+
});
271+
272+
it('derives from host when no fallbackKey is provided', () => {
273+
expect(publishableKeyFromHost('custom-domain.com')).toBe(buildPublishableKey('clerk.custom-domain.com'));
274+
});
275+
276+
it('strips the port from the host before deriving', () => {
277+
expect(publishableKeyFromHost('example.com:3000')).toBe(publishableKeyFromHost('example.com'));
278+
});
279+
280+
it('strips the port even when combined with case normalization', () => {
281+
expect(publishableKeyFromHost('Example.COM:8080')).toBe(publishableKeyFromHost('example.com'));
282+
});
283+
284+
it('throws when host is empty', () => {
285+
expect(() => publishableKeyFromHost('')).toThrow('Host must not be empty.');
286+
});
287+
});
288+
248289
describe('getCookieSuffix(publishableKey, subtle?)', () => {
249290
const cases: Array<[string, string]> = [
250291
['pk_live_ZmFrZS1jbGVyay10ZXN0LmNsZXJrLmFjY291bnRzLmRldiQ=', 'qReyu04C'],

packages/shared/src/keys.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,41 @@ export function buildPublishableKey(frontendApi: string): string {
4343
return `${keyPrefix}${isomorphicBtoa(`${frontendApi}$`)}`;
4444
}
4545

46+
/**
47+
* Derives a publishable key from the current hostname. Intended for multi-domain
48+
* setups (e.g. custom domains on top of a default domain) where the correct key
49+
* must be resolved per request.
50+
*
51+
* Pass the configured publishable key as `fallbackKey` so that development
52+
* instances (pk_test_) are returned as-is instead of being incorrectly derived
53+
* from the host (e.g. localhost).
54+
*
55+
* @example
56+
* // React (use window.location.hostname, not window.location.host, to avoid including the port)
57+
* <ClerkProvider publishableKey={publishableKeyFromHost(window.location.hostname, import.meta.env.VITE_CLERK_PUBLISHABLE_KEY)}>
58+
*
59+
* @example
60+
* // Express (inside clerkMiddleware callback)
61+
* // Validate req.hostname against a known allowlist before passing it in.
62+
* // When `trust proxy` is enabled, req.hostname reads from X-Forwarded-Host
63+
* // and can be spoofed if your proxy is not properly configured.
64+
* const ALLOWED_HOSTS = ['domain-a.com', 'domain-b.com'];
65+
* clerkMiddleware((req) => {
66+
* if (!ALLOWED_HOSTS.includes(req.hostname)) throw new Error('Unknown host');
67+
* return { publishableKey: publishableKeyFromHost(req.hostname, process.env.CLERK_PUBLISHABLE_KEY) };
68+
* })
69+
*/
70+
export function publishableKeyFromHost(host: string, fallbackKey?: string): string {
71+
if (fallbackKey && isDevelopmentFromPublishableKey(fallbackKey)) {
72+
return fallbackKey;
73+
}
74+
const hostname = host.toLowerCase().replace(/:\d+$/, '');
75+
if (!hostname) {
76+
throw new Error('Host must not be empty.');
77+
}
78+
return buildPublishableKey(`clerk.${hostname}`);
79+
}
80+
4681
/**
4782
* Validates that a decoded publishable key has the correct format.
4883
* The decoded value should be a frontend API followed by exactly one '$' at the end.

0 commit comments

Comments
 (0)