Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
866e5af
docs: add accounts portal OAuth consent refactor spec
wobsoriano Apr 15, 2026
ad0617c
feat(express): support dynamic options callback in clerkMiddleware
wobsoriano Apr 24, 2026
1a28062
chore: remove doc
wobsoriano Apr 24, 2026
26f6335
Merge branch 'main' into rob/express-sdk-dynamic-keys
wobsoriano Apr 24, 2026
57f8b79
chore: add changeset
wobsoriano Apr 24, 2026
5edd005
chore: update changeset
wobsoriano Apr 24, 2026
20655d2
feat(shared): add publishableKeyFromHost utility for multi-domain setups
wobsoriano Apr 24, 2026
c3420c8
feat(express): expose publishableKeyFromHost from internal path
wobsoriano Apr 24, 2026
bb09a5a
fix(shared): restore fallbackKey param in publishableKeyFromHost
wobsoriano Apr 24, 2026
70bd92b
update doc
wobsoriano Apr 24, 2026
d22bfd5
chore: add changeset for publishableKeyFromHost
wobsoriano Apr 24, 2026
12b1749
chore: trim express changeset to high-level description
wobsoriano Apr 24, 2026
069ef1e
chore: consolidate changesets
wobsoriano Apr 24, 2026
08143ce
chore: split changesets by package group
wobsoriano Apr 24, 2026
5dee2cc
test(shared): add unit tests for publishableKeyFromHost
wobsoriano Apr 24, 2026
a9a225e
fix(shared): Strip port from host in publishableKeyFromHost and updat…
wobsoriano Apr 24, 2026
ac4550b
fix(shared): Guard against empty host and add trust proxy note in pub…
wobsoriano Apr 24, 2026
4c66694
fix(shared): Align empty host error message with codebase conventions
wobsoriano Apr 24, 2026
62709fa
refactor(express): Remove internal re-export of publishableKeyFromHost
wobsoriano Apr 24, 2026
14d8fd9
docs(shared): Show allowlist pattern in publishableKeyFromHost Expres…
wobsoriano Apr 24, 2026
f48bd81
docs(express): Remove secretKey from clerkMiddleware dynamic keys exa…
wobsoriano Apr 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/brave-lions-fly.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@clerk/shared": patch
"@clerk/react": patch
---

Add `publishableKeyFromHost` utility for resolving the correct publishable key per hostname in multi-domain setups. Re-exported from `@clerk/react/internal`.
5 changes: 5 additions & 0 deletions .changeset/new-kangaroos-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/express": patch
---

Support dynamic options callback in `clerkMiddleware` for multi-domain and multi-tenant setups.
48 changes: 48 additions & 0 deletions packages/express/src/__tests__/clerkMiddleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,54 @@ describe('clerkMiddleware', () => {
});
});

describe('with options callback', () => {
it('accepts a callback function and resolves options per request', async () => {
const optionsCallback = vi.fn().mockResolvedValue({
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
secretKey: 'sk_test_....',
});

const response = await runMiddleware(clerkMiddleware(optionsCallback), {
Cookie: '__clerk_db_jwt=deadbeef;',
}).expect(200, 'Hello world!');

expect(optionsCallback).toHaveBeenCalledOnce();
assertSignedOutDebugHeaders(response);
});

it('calls the callback with the incoming request', async () => {
let capturedHostname: string | undefined;

const optionsCallback = vi.fn().mockImplementation((req: Request) => {
capturedHostname = req.hostname;
return Promise.resolve({
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
secretKey: 'sk_test_....',
});
});

await runMiddleware(clerkMiddleware(optionsCallback), {
Cookie: '__clerk_db_jwt=deadbeef;',
Host: 'example.com',
}).expect(200, 'Hello world!');

expect(capturedHostname).toBe('example.com');
});

it('accepts a synchronous callback (non-Promise return)', async () => {
const optionsCallback = vi.fn().mockReturnValue({
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
secretKey: 'sk_test_....',
});

const response = await runMiddleware(clerkMiddleware(optionsCallback), {
Cookie: '__clerk_db_jwt=deadbeef;',
}).expect(200, 'Hello world!');

assertSignedOutDebugHeaders(response);
});
});

it('calls next with an error when request URL is invalid', () => {
const req = {
url: '//',
Expand Down
42 changes: 34 additions & 8 deletions packages/express/src/clerkMiddleware.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import type { RequestHandler } from 'express';

import { authenticateAndDecorateRequest } from './authenticateRequest';
import type { ClerkMiddlewareOptions } from './types';
import type { ClerkMiddlewareOptions, ClerkMiddlewareOptionsCallback } from './types';

/**
* Middleware that integrates Clerk authentication into your Express application.
* It checks the request's cookies and headers for a session JWT and, if found,
* attaches the Auth object to the request object under the `auth` key.
*
* Accepts either a static options object or a callback that receives the request
* and returns options. The callback form is useful for multi-domain setups where
* the publishable key differs per domain.
*
* @example
* app.use(clerkMiddleware(options));
*
Expand All @@ -17,14 +21,36 @@
*
* @example
* app.use(clerkMiddleware());
*
* @example
* // Dynamic keys per domain
* app.use(clerkMiddleware((req) => ({
* publishableKey: req.hostname === 'example.com' ? PK_A : PK_B,
* })));
*/
export const clerkMiddleware = (options: ClerkMiddlewareOptions = {}): RequestHandler => {
const authMiddleware = authenticateAndDecorateRequest({
...options,
acceptsToken: 'any',
});
export const clerkMiddleware = (
options: ClerkMiddlewareOptions | ClerkMiddlewareOptionsCallback = {},
): RequestHandler => {
if (typeof options !== 'function') {
const authMiddleware = authenticateAndDecorateRequest({
...options,
acceptsToken: 'any',
});
return (request, response, next) => {
authMiddleware(request, response, next);
};
}

return (request, response, next) => {
authMiddleware(request, response, next);
return async (request, response, next) => {

Check warning on line 44 in packages/express/src/clerkMiddleware.ts

View workflow job for this annotation

GitHub Actions / Static analysis

Promise-returning function provided to return value where a void return was expected
try {
const resolvedOptions = await options(request);
const handler = authenticateAndDecorateRequest({
...resolvedOptions,
acceptsToken: 'any',
});
handler(request, response, next);
} catch (err) {
next(err);
}
};
};
2 changes: 1 addition & 1 deletion packages/express/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export * from '@clerk/backend';

export { clerkClient } from './clerkClient';

export type { ExpressRequestWithAuth } from './types';
export type { ClerkMiddlewareOptions, ClerkMiddlewareOptionsCallback, ExpressRequestWithAuth } from './types';
export { clerkMiddleware } from './clerkMiddleware';
export { getAuth } from './getAuth';
export { requireAuth } from './requireAuth';
Expand Down
4 changes: 4 additions & 0 deletions packages/express/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ export interface FrontendApiProxyOptions {
path?: string;
}

export type ClerkMiddlewareOptionsCallback = (
req: ExpressRequest,
) => ClerkMiddlewareOptions | Promise<ClerkMiddlewareOptions>;

export type ClerkMiddlewareOptions = AuthenticateRequestOptions & {
debug?: boolean;
clerkClient?: ClerkClient;
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type React from 'react';
import { ClerkProvider } from './contexts/ClerkProvider';
import type { ClerkProviderProps } from './types';

export { publishableKeyFromHost } from '@clerk/shared/keys';
export { setErrorThrowerOptions } from './errors/errorThrower';
export { MultisessionAppSupport } from './components/controlComponents';
export { useOAuthConsent } from '@clerk/shared/react';
Expand Down
41 changes: 41 additions & 0 deletions packages/shared/src/__tests__/keys.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
isProductionFromSecretKey,
isPublishableKey,
parsePublishableKey,
publishableKeyFromHost,
} from '../keys';

describe('buildPublishableKey(frontendApi)', () => {
Expand Down Expand Up @@ -245,6 +246,46 @@ describe('isProductionFromSecretKey(key)', () => {
});
});

describe('publishableKeyFromHost(host, fallbackKey?)', () => {
it('derives a pk_live_ key from a production hostname', () => {
const result = publishableKeyFromHost('example.com');
expect(result).toMatch(/^pk_live_/);
expect(result).toBe(buildPublishableKey('clerk.example.com'));
});

it('lowercases the host before deriving', () => {
expect(publishableKeyFromHost('Example.COM')).toBe(publishableKeyFromHost('example.com'));
});

it('returns the fallbackKey as-is when it is a development key', () => {
const devKey = 'pk_test_Zm9vLWJhci0xMy5jbGVyay5hY2NvdW50cy5kZXYk';
expect(publishableKeyFromHost('localhost', devKey)).toBe(devKey);
});

it('derives from host when fallbackKey is a production key', () => {
const prodKey = 'pk_live_ZmFrZS1jbGVyay10ZXN0LmNsZXJrLmFjY291bnRzLmRldiQ=';
const result = publishableKeyFromHost('custom-domain.com', prodKey);
expect(result).toMatch(/^pk_live_/);
expect(result).toBe(buildPublishableKey('clerk.custom-domain.com'));
});

it('derives from host when no fallbackKey is provided', () => {
expect(publishableKeyFromHost('custom-domain.com')).toBe(buildPublishableKey('clerk.custom-domain.com'));
});

it('strips the port from the host before deriving', () => {
expect(publishableKeyFromHost('example.com:3000')).toBe(publishableKeyFromHost('example.com'));
});

it('strips the port even when combined with case normalization', () => {
expect(publishableKeyFromHost('Example.COM:8080')).toBe(publishableKeyFromHost('example.com'));
});

it('throws when host is empty', () => {
expect(() => publishableKeyFromHost('')).toThrow('Host must not be empty.');
});
});

describe('getCookieSuffix(publishableKey, subtle?)', () => {
const cases: Array<[string, string]> = [
['pk_live_ZmFrZS1jbGVyay10ZXN0LmNsZXJrLmFjY291bnRzLmRldiQ=', 'qReyu04C'],
Expand Down
35 changes: 35 additions & 0 deletions packages/shared/src/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,41 @@ export function buildPublishableKey(frontendApi: string): string {
return `${keyPrefix}${isomorphicBtoa(`${frontendApi}$`)}`;
}

/**
* Derives a publishable key from the current hostname. Intended for multi-domain
* setups (e.g. custom domains on top of a default domain) where the correct key
* must be resolved per request.
*
* Pass the configured publishable key as `fallbackKey` so that development
* instances (pk_test_) are returned as-is instead of being incorrectly derived
* from the host (e.g. localhost).
*
* @example
* // React (use window.location.hostname, not window.location.host, to avoid including the port)
* <ClerkProvider publishableKey={publishableKeyFromHost(window.location.hostname, import.meta.env.VITE_CLERK_PUBLISHABLE_KEY)}>
*
* @example
* // Express (inside clerkMiddleware callback)
* // Validate req.hostname against a known allowlist before passing it in.
* // When `trust proxy` is enabled, req.hostname reads from X-Forwarded-Host
* // and can be spoofed if your proxy is not properly configured.
* const ALLOWED_HOSTS = ['domain-a.com', 'domain-b.com'];
* clerkMiddleware((req) => {
* if (!ALLOWED_HOSTS.includes(req.hostname)) throw new Error('Unknown host');
* return { publishableKey: publishableKeyFromHost(req.hostname, process.env.CLERK_PUBLISHABLE_KEY) };
* })
*/
export function publishableKeyFromHost(host: string, fallbackKey?: string): string {
Comment thread
wobsoriano marked this conversation as resolved.
if (fallbackKey && isDevelopmentFromPublishableKey(fallbackKey)) {
return fallbackKey;
}
const hostname = host.toLowerCase().replace(/:\d+$/, '');
if (!hostname) {
throw new Error('Host must not be empty.');
}
return buildPublishableKey(`clerk.${hostname}`);
Comment thread
nikosdouvlis marked this conversation as resolved.
}

/**
* Validates that a decoded publishable key has the correct format.
* The decoded value should be a frontend API followed by exactly one '$' at the end.
Expand Down
Loading