From 866e5aff6d47bc54c1d0594703fc9564bdb18e9c Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 14 Apr 2026 17:53:01 -0700 Subject: [PATCH 01/20] docs: add accounts portal OAuth consent refactor spec --- ...-accounts-oauth-consent-refactor-design.md | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-14-accounts-oauth-consent-refactor-design.md diff --git a/docs/superpowers/specs/2026-04-14-accounts-oauth-consent-refactor-design.md b/docs/superpowers/specs/2026-04-14-accounts-oauth-consent-refactor-design.md new file mode 100644 index 00000000000..212aab20054 --- /dev/null +++ b/docs/superpowers/specs/2026-04-14-accounts-oauth-consent-refactor-design.md @@ -0,0 +1,88 @@ +# Accounts Portal OAuth Consent Refactor Design + +> **For agentic workers:** This spec targets the accounts repo at `/Users/wobsoriano/Documents/projects/clerk/accounts`, not the javascript repo. + +**Goal:** Replace the accounts portal's manual OAuth consent implementation with the new `` component from `@clerk/nextjs/internal`, deleting all custom fetch utilities, hidden forms, and types in the process. + +**Context:** The `OAuthConsent` component (in `packages/ui`) now handles the full public path: it reads `client_id`, `redirect_uri`, and `scope` from the URL, fetches consent info via `clerk.oauthApplication.getConsentInfo`, renders scopes, and submits the consent form to `clerk.oauthApplication.buildConsentActionUrl`. The accounts portal's manual implementation duplicates all of this and can be deleted entirely. + +--- + +## Files Deleted + +- `components/oauth-consent/index.tsx` — manual fetch + `__internal_mountOAuthConsent` + hidden forms +- `utils/oauth-consent.ts` — `getConsentInfoForOAuth` FAPI fetch utility +- `types/OAuthConsent.ts` — `OAuthConsentInfo` type (only used by the above two files) + +## Files Modified + +### `types/index.ts` + +Remove the re-export of the deleted type file: + +```diff +- export * from './OAuthConsent'; + export * from './AccountPortalJSON'; + export * from './constants'; +``` + +`constants.ts` and `AccountPortalJSON.ts` are untouched — `DEV_BROWSER_JWT_MARKER` and `CLIENT_COOKIE_NAME` are still used elsewhere. + +### `pages/oauth-consent/[[...index]].tsx` + +Replace the entire file. `getServerSideProps` is removed — clerk-js handles `devBrowserJWT` and session auth automatically, and the new component reads all params from `window.location.search`. The referrer meta tag is kept (FAPI requires the `Origin` header on consent form POSTs). + +```tsx +import React from 'react'; +import Head from 'next/head'; +import { OAuthConsent } from '@clerk/nextjs/internal'; + +export default function ConsentPage(): JSX.Element { + return ( +
+
+ + + + +
+
+ ); +} +``` + +### `e2e/features/oauth-consent.test.ts` + +Error message text changes to match the new component's wording. Happy path assertion changes from hidden inputs (old hidden forms) to the Allow/Deny buttons the new component renders. + +| Old assertion | New assertion | +| --------------------------------------------------------- | ----------------------------------------- | +| `'Error: Authorization failed: The client ID is missing'` | `'The client ID is missing.'` | +| `'Error: Redirect URI not found'` | `'The redirect URI is missing.'` | +| `input[name="consented"][value="true"]` | `button[name="consented"][value="true"]` | +| `input[name="consented"][value="false"]` | `button[name="consented"][value="false"]` | + +### `e2e/unauthenticated/oauth-consent.test.ts` + +The old component returned an explicit `"Error: No session found"` div. The new component is wrapped with `withCoreUserGuard` which renders `null` for unauthenticated users. Update both tests to assert that the Allow button is not visible instead. + +```ts +// Before +await expect(page.getByText('Error: No session found')).toBeVisible(); + +// After +await expect(page.getByRole('button', { name: 'Allow' })).not.toBeVisible(); +``` + +--- + +## What Is Not Changing + +- `types/constants.ts` — stays, used by `utils/devBrowser.ts`, `utils/settings/environment.ts`, `utils/settings/accountPortal.ts` +- `utils/devBrowser.ts` — stays, unrelated to OAuth consent +- The page URL (`/oauth-consent`) and its Next.js route — unchanged +- The referrer meta tag — kept +- CSS class names (`pageContainer`, `componentContainer`) — unchanged From ad0617cec7165ed4c5925f456be84e2d2305db8a Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 23 Apr 2026 21:46:57 -0700 Subject: [PATCH 02/20] feat(express): support dynamic options callback in clerkMiddleware Add ClerkMiddlewareOptionsCallback type so multi-domain/multi-tenant apps can resolve publishableKey and secretKey per request: app.use(clerkMiddleware((req) => ({ publishableKey: req.hostname === 'a.com' ? PK_A : PK_B, secretKey: req.hostname === 'a.com' ? SK_A : SK_B, }))); Static options path is unchanged. Callback path awaits the function result and creates the auth handler per request. --- .../src/__tests__/clerkMiddleware.test.ts | 48 +++++++++++++++++++ packages/express/src/clerkMiddleware.ts | 43 +++++++++++++---- packages/express/src/index.ts | 2 +- packages/express/src/types.ts | 4 ++ 4 files changed, 88 insertions(+), 9 deletions(-) diff --git a/packages/express/src/__tests__/clerkMiddleware.test.ts b/packages/express/src/__tests__/clerkMiddleware.test.ts index f1c9bdbc9d9..4fb8688d067 100644 --- a/packages/express/src/__tests__/clerkMiddleware.test.ts +++ b/packages/express/src/__tests__/clerkMiddleware.test.ts @@ -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: '//', diff --git a/packages/express/src/clerkMiddleware.ts b/packages/express/src/clerkMiddleware.ts index ac360542697..df8e6e76da0 100644 --- a/packages/express/src/clerkMiddleware.ts +++ b/packages/express/src/clerkMiddleware.ts @@ -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)); * @@ -17,14 +21,37 @@ import type { ClerkMiddlewareOptions } from './types'; * * @example * app.use(clerkMiddleware()); + * + * @example + * // Dynamic keys per domain + * app.use(clerkMiddleware((req) => ({ + * publishableKey: req.hostname === 'example.com' ? PK_A : PK_B, + * secretKey: req.hostname === 'example.com' ? SK_A : SK_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) => { + try { + const resolvedOptions = await options(request); + const handler = authenticateAndDecorateRequest({ + ...resolvedOptions, + acceptsToken: 'any', + }); + handler(request, response, next); + } catch (err) { + next(err); + } }; }; diff --git a/packages/express/src/index.ts b/packages/express/src/index.ts index b64dc029cf4..29851e1e4d4 100644 --- a/packages/express/src/index.ts +++ b/packages/express/src/index.ts @@ -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'; diff --git a/packages/express/src/types.ts b/packages/express/src/types.ts index 9eb03b77462..4d889de3dbb 100644 --- a/packages/express/src/types.ts +++ b/packages/express/src/types.ts @@ -27,6 +27,10 @@ export interface FrontendApiProxyOptions { path?: string; } +export type ClerkMiddlewareOptionsCallback = ( + req: ExpressRequest, +) => ClerkMiddlewareOptions | Promise; + export type ClerkMiddlewareOptions = AuthenticateRequestOptions & { debug?: boolean; clerkClient?: ClerkClient; From 1a280628be54337d9a86c033bf58b378294c661c Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 24 Apr 2026 06:26:59 -0700 Subject: [PATCH 03/20] chore: remove doc --- ...-accounts-oauth-consent-refactor-design.md | 88 ------------------- 1 file changed, 88 deletions(-) delete mode 100644 docs/superpowers/specs/2026-04-14-accounts-oauth-consent-refactor-design.md diff --git a/docs/superpowers/specs/2026-04-14-accounts-oauth-consent-refactor-design.md b/docs/superpowers/specs/2026-04-14-accounts-oauth-consent-refactor-design.md deleted file mode 100644 index 212aab20054..00000000000 --- a/docs/superpowers/specs/2026-04-14-accounts-oauth-consent-refactor-design.md +++ /dev/null @@ -1,88 +0,0 @@ -# Accounts Portal OAuth Consent Refactor Design - -> **For agentic workers:** This spec targets the accounts repo at `/Users/wobsoriano/Documents/projects/clerk/accounts`, not the javascript repo. - -**Goal:** Replace the accounts portal's manual OAuth consent implementation with the new `` component from `@clerk/nextjs/internal`, deleting all custom fetch utilities, hidden forms, and types in the process. - -**Context:** The `OAuthConsent` component (in `packages/ui`) now handles the full public path: it reads `client_id`, `redirect_uri`, and `scope` from the URL, fetches consent info via `clerk.oauthApplication.getConsentInfo`, renders scopes, and submits the consent form to `clerk.oauthApplication.buildConsentActionUrl`. The accounts portal's manual implementation duplicates all of this and can be deleted entirely. - ---- - -## Files Deleted - -- `components/oauth-consent/index.tsx` — manual fetch + `__internal_mountOAuthConsent` + hidden forms -- `utils/oauth-consent.ts` — `getConsentInfoForOAuth` FAPI fetch utility -- `types/OAuthConsent.ts` — `OAuthConsentInfo` type (only used by the above two files) - -## Files Modified - -### `types/index.ts` - -Remove the re-export of the deleted type file: - -```diff -- export * from './OAuthConsent'; - export * from './AccountPortalJSON'; - export * from './constants'; -``` - -`constants.ts` and `AccountPortalJSON.ts` are untouched — `DEV_BROWSER_JWT_MARKER` and `CLIENT_COOKIE_NAME` are still used elsewhere. - -### `pages/oauth-consent/[[...index]].tsx` - -Replace the entire file. `getServerSideProps` is removed — clerk-js handles `devBrowserJWT` and session auth automatically, and the new component reads all params from `window.location.search`. The referrer meta tag is kept (FAPI requires the `Origin` header on consent form POSTs). - -```tsx -import React from 'react'; -import Head from 'next/head'; -import { OAuthConsent } from '@clerk/nextjs/internal'; - -export default function ConsentPage(): JSX.Element { - return ( -
-
- - - - -
-
- ); -} -``` - -### `e2e/features/oauth-consent.test.ts` - -Error message text changes to match the new component's wording. Happy path assertion changes from hidden inputs (old hidden forms) to the Allow/Deny buttons the new component renders. - -| Old assertion | New assertion | -| --------------------------------------------------------- | ----------------------------------------- | -| `'Error: Authorization failed: The client ID is missing'` | `'The client ID is missing.'` | -| `'Error: Redirect URI not found'` | `'The redirect URI is missing.'` | -| `input[name="consented"][value="true"]` | `button[name="consented"][value="true"]` | -| `input[name="consented"][value="false"]` | `button[name="consented"][value="false"]` | - -### `e2e/unauthenticated/oauth-consent.test.ts` - -The old component returned an explicit `"Error: No session found"` div. The new component is wrapped with `withCoreUserGuard` which renders `null` for unauthenticated users. Update both tests to assert that the Allow button is not visible instead. - -```ts -// Before -await expect(page.getByText('Error: No session found')).toBeVisible(); - -// After -await expect(page.getByRole('button', { name: 'Allow' })).not.toBeVisible(); -``` - ---- - -## What Is Not Changing - -- `types/constants.ts` — stays, used by `utils/devBrowser.ts`, `utils/settings/environment.ts`, `utils/settings/accountPortal.ts` -- `utils/devBrowser.ts` — stays, unrelated to OAuth consent -- The page URL (`/oauth-consent`) and its Next.js route — unchanged -- The referrer meta tag — kept -- CSS class names (`pageContainer`, `componentContainer`) — unchanged From 57f8b79ab07b017744d0ce0ea865b11c126b8df0 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Fri, 24 Apr 2026 09:16:59 -0700 Subject: [PATCH 04/20] chore: add changeset --- .changeset/new-kangaroos-search.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/new-kangaroos-search.md diff --git a/.changeset/new-kangaroos-search.md b/.changeset/new-kangaroos-search.md new file mode 100644 index 00000000000..34002a8e74c --- /dev/null +++ b/.changeset/new-kangaroos-search.md @@ -0,0 +1,13 @@ +--- +"@clerk/express": patch +--- + +Support dynamic options callback in `clerkMiddleware`: + + +```ts +app.use(clerkMiddleware((req) => ({ + publishableKey: req.hostname === 'example.com' ? PK_A : PK_B, + secretKey: req.hostname === 'example.com' ? SK_A : SK_B, +}))); +``` From 5edd00569e5f6dd16af43f359e88fd8c846e1ab7 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Fri, 24 Apr 2026 09:17:22 -0700 Subject: [PATCH 05/20] chore: update changeset --- .changeset/new-kangaroos-search.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/new-kangaroos-search.md b/.changeset/new-kangaroos-search.md index 34002a8e74c..b234c5613ce 100644 --- a/.changeset/new-kangaroos-search.md +++ b/.changeset/new-kangaroos-search.md @@ -4,6 +4,7 @@ Support dynamic options callback in `clerkMiddleware`: +Usage: ```ts app.use(clerkMiddleware((req) => ({ From 20655d211236b0da14020509e6c300223e28088d Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 24 Apr 2026 12:44:43 -0700 Subject: [PATCH 06/20] feat(shared): add publishableKeyFromHost utility for multi-domain setups Derives a production publishable key from the current request hostname, falling back to the configured key for development instances (pk_test_). Built on the existing buildPublishableKey and isDevelopmentFromPublishableKey helpers. Re-exported from @clerk/react/internal so Replit and other multi-domain apps can use it without additional dependencies: import { publishableKeyFromHost } from '@clerk/react/internal' // React // Express (with the new clerkMiddleware callback) clerkMiddleware((req) => ({ publishableKey: publishableKeyFromHost(req.hostname, process.env.CLERK_PUBLISHABLE_KEY), })) --- packages/react/src/internal.ts | 1 + packages/shared/src/keys.ts | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/packages/react/src/internal.ts b/packages/react/src/internal.ts index 0161dab0279..99f90c77fbe 100644 --- a/packages/react/src/internal.ts +++ b/packages/react/src/internal.ts @@ -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'; diff --git a/packages/shared/src/keys.ts b/packages/shared/src/keys.ts index 9b4ac37a02a..e4316dda122 100644 --- a/packages/shared/src/keys.ts +++ b/packages/shared/src/keys.ts @@ -43,6 +43,31 @@ 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 env publishable key as `fallbackKey` so that development instances + * (pk_test_) are returned as-is instead of being incorrectly derived. + * + * @example + * // React + * + * + * @example + * // Express (inside clerkMiddleware callback) + * clerkMiddleware((req) => ({ + * publishableKey: publishableKeyFromHost(req.hostname, process.env.CLERK_PUBLISHABLE_KEY), + * })) + */ +export function publishableKeyFromHost(host: string, fallbackKey?: string): string { + if (fallbackKey && isDevelopmentFromPublishableKey(fallbackKey)) { + return fallbackKey; + } + return buildPublishableKey(`clerk.${host.toLowerCase()}`); +} + /** * 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. From c3420c82af509e2bcbcfa4a16df859f0b89decb4 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 24 Apr 2026 12:50:00 -0700 Subject: [PATCH 07/20] feat(express): expose publishableKeyFromHost from internal path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add @clerk/express/internal re-exporting publishableKeyFromHost from @clerk/shared/keys. Mirrors the @clerk/react/internal pattern. Also simplifies publishableKeyFromHost to take only host — buildPublishableKey already handles dev vs prod detection via FAPI host patterns, so no fallback key parameter is needed. Usage: import { publishableKeyFromHost } from '@clerk/express/internal' clerkMiddleware((req) => ({ publishableKey: publishableKeyFromHost(req.hostname), })) --- packages/express/package.json | 10 ++++++++++ packages/express/src/internal.ts | 1 + packages/express/tsup.config.ts | 1 + packages/shared/src/keys.ts | 12 +++--------- 4 files changed, 15 insertions(+), 9 deletions(-) create mode 100644 packages/express/src/internal.ts diff --git a/packages/express/package.json b/packages/express/package.json index a538fe0264d..1e5b8e47a74 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -33,6 +33,16 @@ "default": "./dist/index.js" } }, + "./internal": { + "import": { + "types": "./dist/internal.d.mts", + "default": "./dist/internal.mjs" + }, + "require": { + "types": "./dist/internal.d.ts", + "default": "./dist/internal.js" + } + }, "./webhooks": { "import": { "types": "./dist/webhooks.d.mts", diff --git a/packages/express/src/internal.ts b/packages/express/src/internal.ts new file mode 100644 index 00000000000..614ae890a55 --- /dev/null +++ b/packages/express/src/internal.ts @@ -0,0 +1 @@ +export { publishableKeyFromHost } from '@clerk/shared/keys'; diff --git a/packages/express/tsup.config.ts b/packages/express/tsup.config.ts index 2b5ee461c6b..f3b218530bc 100644 --- a/packages/express/tsup.config.ts +++ b/packages/express/tsup.config.ts @@ -9,6 +9,7 @@ export default defineConfig(overrideOptions => { return { entry: { index: './src/index.ts', + internal: './src/internal.ts', webhooks: './src/webhooks.ts', types: './src/types/index.ts', }, diff --git a/packages/shared/src/keys.ts b/packages/shared/src/keys.ts index e4316dda122..d20e9db8186 100644 --- a/packages/shared/src/keys.ts +++ b/packages/shared/src/keys.ts @@ -48,23 +48,17 @@ export function buildPublishableKey(frontendApi: string): string { * setups (e.g. custom domains on top of a default domain) where the correct key * must be resolved per request. * - * Pass the env publishable key as `fallbackKey` so that development instances - * (pk_test_) are returned as-is instead of being incorrectly derived. - * * @example * // React - * + * * * @example * // Express (inside clerkMiddleware callback) * clerkMiddleware((req) => ({ - * publishableKey: publishableKeyFromHost(req.hostname, process.env.CLERK_PUBLISHABLE_KEY), + * publishableKey: publishableKeyFromHost(req.hostname), * })) */ -export function publishableKeyFromHost(host: string, fallbackKey?: string): string { - if (fallbackKey && isDevelopmentFromPublishableKey(fallbackKey)) { - return fallbackKey; - } +export function publishableKeyFromHost(host: string): string { return buildPublishableKey(`clerk.${host.toLowerCase()}`); } From bb09a5afb427f8f82d8ad319b33ed0bca5d2e3d2 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 24 Apr 2026 12:59:03 -0700 Subject: [PATCH 08/20] fix(shared): restore fallbackKey param in publishableKeyFromHost Without it, dev instances on localhost produce an incorrect pk_live_ key. If fallbackKey is a pk_test_ key it is returned as-is; otherwise the key is derived from the host for production multi-domain setups. --- packages/shared/src/keys.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/keys.ts b/packages/shared/src/keys.ts index d20e9db8186..19dcc6d26df 100644 --- a/packages/shared/src/keys.ts +++ b/packages/shared/src/keys.ts @@ -48,17 +48,24 @@ export function buildPublishableKey(frontendApi: string): string { * 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 - * + * * * @example * // Express (inside clerkMiddleware callback) * clerkMiddleware((req) => ({ - * publishableKey: publishableKeyFromHost(req.hostname), + * publishableKey: publishableKeyFromHost(req.hostname, process.env.CLERK_PUBLISHABLE_KEY), * })) */ -export function publishableKeyFromHost(host: string): string { +export function publishableKeyFromHost(host: string, fallbackKey?: string): string { + if (fallbackKey && isDevelopmentFromPublishableKey(fallbackKey)) { + return fallbackKey; + } return buildPublishableKey(`clerk.${host.toLowerCase()}`); } From 70bd92ba85539aefb880f33e5fcd8cc1f1199756 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 24 Apr 2026 13:00:11 -0700 Subject: [PATCH 09/20] update doc --- packages/shared/src/keys.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/keys.ts b/packages/shared/src/keys.ts index 19dcc6d26df..d4ebde335d0 100644 --- a/packages/shared/src/keys.ts +++ b/packages/shared/src/keys.ts @@ -54,7 +54,7 @@ export function buildPublishableKey(frontendApi: string): string { * * @example * // React - * + * * * @example * // Express (inside clerkMiddleware callback) From d22bfd5fca52dadb56cde8c8c90b2bf84ca7dbce Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 24 Apr 2026 13:03:06 -0700 Subject: [PATCH 10/20] chore: add changeset for publishableKeyFromHost --- .changeset/brave-lions-fly.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .changeset/brave-lions-fly.md diff --git a/.changeset/brave-lions-fly.md b/.changeset/brave-lions-fly.md new file mode 100644 index 00000000000..a3556975ce0 --- /dev/null +++ b/.changeset/brave-lions-fly.md @@ -0,0 +1,31 @@ +--- +"@clerk/shared": patch +"@clerk/react": patch +"@clerk/express": patch +--- + +Add `publishableKeyFromHost` utility for multi-domain setups. + +Derives the correct publishable key from the current request hostname, enabling +apps with multiple custom domains to resolve the right key per request without +any manual lookup table. + +Pass your configured publishable key as `fallbackKey` to ensure development +instances (`pk_test_`) are returned as-is — otherwise deriving from a host +like `localhost` would produce an incorrect `pk_live_` key. + +```ts +// React +import { publishableKeyFromHost } from '@clerk/react/internal'; + + +``` + +```ts +// Express — combine with the new clerkMiddleware callback +import { publishableKeyFromHost } from '@clerk/express/internal'; + +app.use(clerkMiddleware((req) => ({ + publishableKey: publishableKeyFromHost(req.hostname, process.env.CLERK_PUBLISHABLE_KEY), +}))); +``` From 12b1749add7ac2175e518417e99f3f836a797adb Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 24 Apr 2026 13:04:43 -0700 Subject: [PATCH 11/20] chore: trim express changeset to high-level description --- .changeset/new-kangaroos-search.md | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/.changeset/new-kangaroos-search.md b/.changeset/new-kangaroos-search.md index b234c5613ce..2e38f3138cf 100644 --- a/.changeset/new-kangaroos-search.md +++ b/.changeset/new-kangaroos-search.md @@ -2,13 +2,4 @@ "@clerk/express": patch --- -Support dynamic options callback in `clerkMiddleware`: - -Usage: - -```ts -app.use(clerkMiddleware((req) => ({ - publishableKey: req.hostname === 'example.com' ? PK_A : PK_B, - secretKey: req.hostname === 'example.com' ? SK_A : SK_B, -}))); -``` +Support dynamic options callback in `clerkMiddleware` for multi-domain and multi-tenant setups. From 069ef1ea2cffb7b55771c5fa7cdbfbeac15b6c56 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 24 Apr 2026 13:05:21 -0700 Subject: [PATCH 12/20] chore: consolidate changesets --- .changeset/brave-lions-fly.md | 26 ++------------------------ .changeset/new-kangaroos-search.md | 5 ----- 2 files changed, 2 insertions(+), 29 deletions(-) delete mode 100644 .changeset/new-kangaroos-search.md diff --git a/.changeset/brave-lions-fly.md b/.changeset/brave-lions-fly.md index a3556975ce0..6065d79555e 100644 --- a/.changeset/brave-lions-fly.md +++ b/.changeset/brave-lions-fly.md @@ -4,28 +4,6 @@ "@clerk/express": patch --- -Add `publishableKeyFromHost` utility for multi-domain setups. +Add `publishableKeyFromHost` utility for resolving the correct publishable key per hostname in multi-domain setups. Re-exported from `@clerk/react/internal` and `@clerk/express/internal`. -Derives the correct publishable key from the current request hostname, enabling -apps with multiple custom domains to resolve the right key per request without -any manual lookup table. - -Pass your configured publishable key as `fallbackKey` to ensure development -instances (`pk_test_`) are returned as-is — otherwise deriving from a host -like `localhost` would produce an incorrect `pk_live_` key. - -```ts -// React -import { publishableKeyFromHost } from '@clerk/react/internal'; - - -``` - -```ts -// Express — combine with the new clerkMiddleware callback -import { publishableKeyFromHost } from '@clerk/express/internal'; - -app.use(clerkMiddleware((req) => ({ - publishableKey: publishableKeyFromHost(req.hostname, process.env.CLERK_PUBLISHABLE_KEY), -}))); -``` +Support dynamic options callback in `clerkMiddleware` for multi-domain and multi-tenant setups. diff --git a/.changeset/new-kangaroos-search.md b/.changeset/new-kangaroos-search.md deleted file mode 100644 index 2e38f3138cf..00000000000 --- a/.changeset/new-kangaroos-search.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@clerk/express": patch ---- - -Support dynamic options callback in `clerkMiddleware` for multi-domain and multi-tenant setups. From 08143ce9b90926ba0f9deba1ce941ff939db35f0 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 24 Apr 2026 13:11:36 -0700 Subject: [PATCH 13/20] chore: split changesets by package group --- .changeset/brave-lions-fly.md | 3 --- .changeset/new-kangaroos-search.md | 5 +++++ 2 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 .changeset/new-kangaroos-search.md diff --git a/.changeset/brave-lions-fly.md b/.changeset/brave-lions-fly.md index 6065d79555e..b3732471ade 100644 --- a/.changeset/brave-lions-fly.md +++ b/.changeset/brave-lions-fly.md @@ -1,9 +1,6 @@ --- "@clerk/shared": patch "@clerk/react": patch -"@clerk/express": patch --- Add `publishableKeyFromHost` utility for resolving the correct publishable key per hostname in multi-domain setups. Re-exported from `@clerk/react/internal` and `@clerk/express/internal`. - -Support dynamic options callback in `clerkMiddleware` for multi-domain and multi-tenant setups. diff --git a/.changeset/new-kangaroos-search.md b/.changeset/new-kangaroos-search.md new file mode 100644 index 00000000000..2e38f3138cf --- /dev/null +++ b/.changeset/new-kangaroos-search.md @@ -0,0 +1,5 @@ +--- +"@clerk/express": patch +--- + +Support dynamic options callback in `clerkMiddleware` for multi-domain and multi-tenant setups. From 5dee2cc26a92f1ce9810db570e988932c4524f28 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 24 Apr 2026 13:14:34 -0700 Subject: [PATCH 14/20] test(shared): add unit tests for publishableKeyFromHost --- packages/shared/src/__tests__/keys.spec.ts | 29 ++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/shared/src/__tests__/keys.spec.ts b/packages/shared/src/__tests__/keys.spec.ts index a4e493171ac..8ff696260a0 100644 --- a/packages/shared/src/__tests__/keys.spec.ts +++ b/packages/shared/src/__tests__/keys.spec.ts @@ -10,6 +10,7 @@ import { isProductionFromSecretKey, isPublishableKey, parsePublishableKey, + publishableKeyFromHost, } from '../keys'; describe('buildPublishableKey(frontendApi)', () => { @@ -245,6 +246,34 @@ 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')); + }); +}); + describe('getCookieSuffix(publishableKey, subtle?)', () => { const cases: Array<[string, string]> = [ ['pk_live_ZmFrZS1jbGVyay10ZXN0LmNsZXJrLmFjY291bnRzLmRldiQ=', 'qReyu04C'], From a9a225e93d91206127bf5e2db3d6325a35abcd46 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 24 Apr 2026 13:40:00 -0700 Subject: [PATCH 15/20] fix(shared): Strip port from host in publishableKeyFromHost and update docstring example --- packages/shared/src/__tests__/keys.spec.ts | 8 ++++++++ packages/shared/src/keys.ts | 7 ++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/__tests__/keys.spec.ts b/packages/shared/src/__tests__/keys.spec.ts index 8ff696260a0..e20632921d1 100644 --- a/packages/shared/src/__tests__/keys.spec.ts +++ b/packages/shared/src/__tests__/keys.spec.ts @@ -272,6 +272,14 @@ describe('publishableKeyFromHost(host, fallbackKey?)', () => { 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')); + }); }); describe('getCookieSuffix(publishableKey, subtle?)', () => { diff --git a/packages/shared/src/keys.ts b/packages/shared/src/keys.ts index d4ebde335d0..b303435751a 100644 --- a/packages/shared/src/keys.ts +++ b/packages/shared/src/keys.ts @@ -53,8 +53,8 @@ export function buildPublishableKey(frontendApi: string): string { * from the host (e.g. localhost). * * @example - * // React - * + * // React (use window.location.hostname, not window.location.host, to avoid including the port) + * * * @example * // Express (inside clerkMiddleware callback) @@ -66,7 +66,8 @@ export function publishableKeyFromHost(host: string, fallbackKey?: string): stri if (fallbackKey && isDevelopmentFromPublishableKey(fallbackKey)) { return fallbackKey; } - return buildPublishableKey(`clerk.${host.toLowerCase()}`); + const hostname = host.toLowerCase().replace(/:\d+$/, ''); + return buildPublishableKey(`clerk.${hostname}`); } /** From ac4550b8df5d14f2424f112cf3e13a41798ea3c2 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 24 Apr 2026 14:00:38 -0700 Subject: [PATCH 16/20] fix(shared): Guard against empty host and add trust proxy note in publishableKeyFromHost --- packages/shared/src/__tests__/keys.spec.ts | 4 ++++ packages/shared/src/keys.ts | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/packages/shared/src/__tests__/keys.spec.ts b/packages/shared/src/__tests__/keys.spec.ts index e20632921d1..524d6f8beff 100644 --- a/packages/shared/src/__tests__/keys.spec.ts +++ b/packages/shared/src/__tests__/keys.spec.ts @@ -280,6 +280,10 @@ describe('publishableKeyFromHost(host, fallbackKey?)', () => { 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('publishableKeyFromHost: host must not be empty.'); + }); }); describe('getCookieSuffix(publishableKey, subtle?)', () => { diff --git a/packages/shared/src/keys.ts b/packages/shared/src/keys.ts index b303435751a..dba998f936b 100644 --- a/packages/shared/src/keys.ts +++ b/packages/shared/src/keys.ts @@ -58,6 +58,8 @@ export function buildPublishableKey(frontendApi: string): string { * * @example * // Express (inside clerkMiddleware callback) + * // Note: when `trust proxy` is enabled, req.hostname reads from X-Forwarded-Host, + * // which can be spoofed if your proxy is not properly configured. * clerkMiddleware((req) => ({ * publishableKey: publishableKeyFromHost(req.hostname, process.env.CLERK_PUBLISHABLE_KEY), * })) @@ -67,6 +69,9 @@ export function publishableKeyFromHost(host: string, fallbackKey?: string): stri return fallbackKey; } const hostname = host.toLowerCase().replace(/:\d+$/, ''); + if (!hostname) { + throw new Error('publishableKeyFromHost: host must not be empty.'); + } return buildPublishableKey(`clerk.${hostname}`); } From 4c66694b930dd735aaaa0412d7116e8d1a8cde1a Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 24 Apr 2026 14:01:40 -0700 Subject: [PATCH 17/20] fix(shared): Align empty host error message with codebase conventions --- packages/shared/src/__tests__/keys.spec.ts | 2 +- packages/shared/src/keys.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/__tests__/keys.spec.ts b/packages/shared/src/__tests__/keys.spec.ts index 524d6f8beff..4242d8111dd 100644 --- a/packages/shared/src/__tests__/keys.spec.ts +++ b/packages/shared/src/__tests__/keys.spec.ts @@ -282,7 +282,7 @@ describe('publishableKeyFromHost(host, fallbackKey?)', () => { }); it('throws when host is empty', () => { - expect(() => publishableKeyFromHost('')).toThrow('publishableKeyFromHost: host must not be empty.'); + expect(() => publishableKeyFromHost('')).toThrow('Host must not be empty.'); }); }); diff --git a/packages/shared/src/keys.ts b/packages/shared/src/keys.ts index dba998f936b..a28d8fc68b3 100644 --- a/packages/shared/src/keys.ts +++ b/packages/shared/src/keys.ts @@ -70,7 +70,7 @@ export function publishableKeyFromHost(host: string, fallbackKey?: string): stri } const hostname = host.toLowerCase().replace(/:\d+$/, ''); if (!hostname) { - throw new Error('publishableKeyFromHost: host must not be empty.'); + throw new Error('Host must not be empty.'); } return buildPublishableKey(`clerk.${hostname}`); } From 62709faab0178853fe80fbbd4c7186fd900f665a Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 24 Apr 2026 14:25:53 -0700 Subject: [PATCH 18/20] refactor(express): Remove internal re-export of publishableKeyFromHost --- .changeset/brave-lions-fly.md | 2 +- packages/express/package.json | 10 ---------- packages/express/src/internal.ts | 1 - packages/express/tsup.config.ts | 1 - 4 files changed, 1 insertion(+), 13 deletions(-) delete mode 100644 packages/express/src/internal.ts diff --git a/.changeset/brave-lions-fly.md b/.changeset/brave-lions-fly.md index b3732471ade..4975e66360e 100644 --- a/.changeset/brave-lions-fly.md +++ b/.changeset/brave-lions-fly.md @@ -3,4 +3,4 @@ "@clerk/react": patch --- -Add `publishableKeyFromHost` utility for resolving the correct publishable key per hostname in multi-domain setups. Re-exported from `@clerk/react/internal` and `@clerk/express/internal`. +Add `publishableKeyFromHost` utility for resolving the correct publishable key per hostname in multi-domain setups. Re-exported from `@clerk/react/internal`. diff --git a/packages/express/package.json b/packages/express/package.json index 1e5b8e47a74..a538fe0264d 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -33,16 +33,6 @@ "default": "./dist/index.js" } }, - "./internal": { - "import": { - "types": "./dist/internal.d.mts", - "default": "./dist/internal.mjs" - }, - "require": { - "types": "./dist/internal.d.ts", - "default": "./dist/internal.js" - } - }, "./webhooks": { "import": { "types": "./dist/webhooks.d.mts", diff --git a/packages/express/src/internal.ts b/packages/express/src/internal.ts deleted file mode 100644 index 614ae890a55..00000000000 --- a/packages/express/src/internal.ts +++ /dev/null @@ -1 +0,0 @@ -export { publishableKeyFromHost } from '@clerk/shared/keys'; diff --git a/packages/express/tsup.config.ts b/packages/express/tsup.config.ts index f3b218530bc..2b5ee461c6b 100644 --- a/packages/express/tsup.config.ts +++ b/packages/express/tsup.config.ts @@ -9,7 +9,6 @@ export default defineConfig(overrideOptions => { return { entry: { index: './src/index.ts', - internal: './src/internal.ts', webhooks: './src/webhooks.ts', types: './src/types/index.ts', }, From 14d8fd92cc2cbde0e5ae0361734f9ee709baddef Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 24 Apr 2026 14:45:57 -0700 Subject: [PATCH 19/20] docs(shared): Show allowlist pattern in publishableKeyFromHost Express example --- packages/shared/src/keys.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/keys.ts b/packages/shared/src/keys.ts index a28d8fc68b3..c9013a45c83 100644 --- a/packages/shared/src/keys.ts +++ b/packages/shared/src/keys.ts @@ -58,11 +58,14 @@ export function buildPublishableKey(frontendApi: string): string { * * @example * // Express (inside clerkMiddleware callback) - * // Note: when `trust proxy` is enabled, req.hostname reads from X-Forwarded-Host, - * // which can be spoofed if your proxy is not properly configured. - * clerkMiddleware((req) => ({ - * publishableKey: publishableKeyFromHost(req.hostname, process.env.CLERK_PUBLISHABLE_KEY), - * })) + * // 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 { if (fallbackKey && isDevelopmentFromPublishableKey(fallbackKey)) { From f48bd81a107856ae012198a7ac1b118759884418 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 24 Apr 2026 14:47:29 -0700 Subject: [PATCH 20/20] docs(express): Remove secretKey from clerkMiddleware dynamic keys example --- packages/express/src/clerkMiddleware.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/express/src/clerkMiddleware.ts b/packages/express/src/clerkMiddleware.ts index df8e6e76da0..43a86437461 100644 --- a/packages/express/src/clerkMiddleware.ts +++ b/packages/express/src/clerkMiddleware.ts @@ -26,7 +26,6 @@ import type { ClerkMiddlewareOptions, ClerkMiddlewareOptionsCallback } from './t * // Dynamic keys per domain * app.use(clerkMiddleware((req) => ({ * publishableKey: req.hostname === 'example.com' ? PK_A : PK_B, - * secretKey: req.hostname === 'example.com' ? SK_A : SK_B, * }))); */ export const clerkMiddleware = (