diff --git a/.changeset/young-pigs-live.md b/.changeset/young-pigs-live.md
new file mode 100644
index 0000000000..5e4ce82895
--- /dev/null
+++ b/.changeset/young-pigs-live.md
@@ -0,0 +1,11 @@
+---
+'@clerk/backend': minor
+'@clerk/nextjs': minor
+---
+
+Introduces dynamic keys from `clerkMiddleware`, allowing access by server-side helpers like `auth`. Keys such as `signUpUrl`, `signInUrl`, `publishableKey` and `secretKey` are securely encrypted using AES algorithm.
+
+- When providing `secretKey`, `CLERK_ENCRYPTION_KEY` is required as the encryption key. If `secretKey` is not provided, `CLERK_SECRET_KEY` is used by default.
+- `clerkClient` from `@clerk/nextjs` should now be called as a function, and its singleton form is deprecated. This change allows the Clerk backend client to read keys from the current request, which is necessary to support dynamic keys.
+
+For more information, refer to the documentation: https://clerk.com/docs/references/nextjs/clerk-middleware#dynamic-keys
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3e3973e515..ce95993ec2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -206,6 +206,7 @@ jobs:
E2E_CLERK_VERSION: 'latest'
E2E_NEXTJS_VERSION: ${{ matrix.next-version }}
E2E_PROJECT: ${{ matrix.test-project }}
+ E2E_CLERK_ENCRYPTION_KEY: ${{ matrix.clerk-encryption-key }}
INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }}
MAILSAC_API_KEY: ${{ secrets.MAILSAC_API_KEY }}
diff --git a/integration/constants.ts b/integration/constants.ts
index 0154f72f82..d58e396ed1 100644
--- a/integration/constants.ts
+++ b/integration/constants.ts
@@ -67,6 +67,11 @@ export const constants = {
* The version of the dependency to use, controlled programmatically.
*/
E2E_CLERK_VERSION: process.env.E2E_CLERK_VERSION,
+ /**
+ * Key used to encrypt request data for Next.js dynamic keys.
+ * @ref https://clerk.com/docs/references/nextjs/clerk-middleware#dynamic-keys
+ */
+ E2E_CLERK_ENCRYPTION_KEY: process.env.CLERK_ENCRYPTION_KEY,
/**
* PK and SK pairs from the env to use for integration tests.
*/
diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts
index 55b086554b..ffa819df68 100644
--- a/integration/presets/envs.ts
+++ b/integration/presets/envs.ts
@@ -29,7 +29,8 @@ const withEmailCodes = environmentConfig()
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['with-email-codes'].pk)
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up')
- .setEnvVariable('public', 'CLERK_JS_URL', constants.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js');
+ .setEnvVariable('public', 'CLERK_JS_URL', constants.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js')
+ .setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY);
const withEmailLinks = environmentConfig()
.setId('withEmailLinks')
@@ -81,6 +82,12 @@ const withAPCore2ClerkV4 = environmentConfig()
.setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['core-2-all-enabled'].sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['core-2-all-enabled'].pk);
+const withDynamicKeys = withEmailCodes
+ .clone()
+ .setId('withDynamicKeys')
+ .setEnvVariable('private', 'CLERK_SECRET_KEY', '')
+ .setEnvVariable('private', 'CLERK_DYNAMIC_SECRET_KEY', envKeys['with-email-codes'].sk);
+
export const envs = {
withEmailCodes,
withEmailLinks,
@@ -90,4 +97,5 @@ export const envs = {
withAPCore1ClerkV4,
withAPCore2ClerkLatest,
withAPCore2ClerkV4,
+ withDynamicKeys,
} as const;
diff --git a/integration/tests/dynamic-keys.test.ts b/integration/tests/dynamic-keys.test.ts
new file mode 100644
index 0000000000..4c08e8656c
--- /dev/null
+++ b/integration/tests/dynamic-keys.test.ts
@@ -0,0 +1,101 @@
+import { expect, test } from '@playwright/test';
+
+import type { Application } from '../models/application';
+import { appConfigs } from '../presets';
+import { createTestUtils } from '../testUtils';
+
+test.describe('dynamic keys @nextjs', () => {
+ test.describe.configure({ mode: 'parallel' });
+ let app: Application;
+
+ test.beforeAll(async () => {
+ app = await appConfigs.next.appRouter
+ .clone()
+ .addFile(
+ 'src/middleware.ts',
+ () => `import { clerkClient, clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
+ import { NextResponse } from 'next/server'
+
+ const isProtectedRoute = createRouteMatcher(['/protected']);
+ const shouldFetchBapi = createRouteMatcher(['/fetch-bapi-from-middleware']);
+
+ export default clerkMiddleware(async (auth, request) => {
+ if (isProtectedRoute(request)) {
+ auth().protect();
+ }
+
+ if (shouldFetchBapi(request)){
+ const count = await clerkClient().users.getCount();
+
+ if (count){
+ return NextResponse.redirect(new URL('/users-count', request.url))
+ }
+ }
+ }, {
+ secretKey: process.env.CLERK_DYNAMIC_SECRET_KEY,
+ signInUrl: '/foobar'
+ });
+
+ export const config = {
+ matcher: ['/((?!.*\\\\..*|_next).*)', '/', '/(api|trpc)(.*)'],
+ };`,
+ )
+ .addFile(
+ 'src/app/users-count/page.tsx',
+ () => `import { clerkClient } from '@clerk/nextjs/server'
+
+ export default async function Page(){
+ const count = await clerkClient().users.getCount()
+
+ return
Users count: {count}
+ }
+ `,
+ )
+ .commit();
+
+ await app.setup();
+ await app.withEnv(appConfigs.envs.withDynamicKeys);
+ await app.dev();
+ });
+
+ test.afterAll(async () => {
+ await app.teardown();
+ });
+
+ test.afterEach(async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.page.signOut();
+ await u.page.context().clearCookies();
+ });
+
+ test('redirects to `signInUrl` on `auth().protect()`', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+
+ await u.page.goToStart();
+
+ await u.po.expect.toBeSignedOut();
+
+ await u.page.goToRelative('/protected');
+
+ await u.page.waitForURL(/foobar/);
+ });
+
+ test('resolves auth signature with `secretKey` on `auth().protect()`', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.page.goToRelative('/page-protected');
+ await u.page.waitForURL(/foobar/);
+ });
+
+ test('calls `clerkClient` with dynamic keys from application runtime', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.page.goToRelative('/users-count');
+ await expect(u.page.getByText(/Users count/i)).toBeVisible();
+ });
+
+ test('calls `clerkClient` with dynamic keys from middleware runtime', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.page.goToRelative('/fetch-bapi-from-middleware');
+ await u.page.waitForAppUrl('/users-count');
+ await expect(u.page.getByText(/Users count/i)).toBeVisible();
+ });
+});
diff --git a/integration/tests/handshake.test.ts b/integration/tests/handshake.test.ts
index 5057145ef2..7d8cd203e0 100644
--- a/integration/tests/handshake.test.ts
+++ b/integration/tests/handshake.test.ts
@@ -8,7 +8,7 @@ import { generateConfig, getJwksFromSecretKey } from '../testUtils/handshake';
const PORT = 4199;
-test.skip('Client handshake @generic', () => {
+test.describe('Client handshake @generic', () => {
test.describe.configure({ mode: 'serial' });
let app: Application;
diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts
index fa7ac5c568..7fff948c6b 100644
--- a/packages/backend/src/constants.ts
+++ b/packages/backend/src/constants.ts
@@ -39,6 +39,7 @@ const Headers = {
AuthMessage: 'x-clerk-auth-message',
ClerkUrl: 'x-clerk-clerk-url',
EnableDebug: 'x-clerk-debug',
+ ClerkRequestData: 'x-clerk-request-data',
ClerkRedirectTo: 'x-clerk-redirect-to',
CloudFrontForwardedProto: 'cloudfront-forwarded-proto',
Authorization: 'authorization',
diff --git a/packages/fastify/src/__tests__/__snapshots__/constants.test.ts.snap b/packages/fastify/src/__tests__/__snapshots__/constants.test.ts.snap
index 023875d97b..a5daa66361 100644
--- a/packages/fastify/src/__tests__/__snapshots__/constants.test.ts.snap
+++ b/packages/fastify/src/__tests__/__snapshots__/constants.test.ts.snap
@@ -19,6 +19,7 @@ exports[`constants from environment variables 1`] = `
"AuthToken": "x-clerk-auth-token",
"Authorization": "authorization",
"ClerkRedirectTo": "x-clerk-redirect-to",
+ "ClerkRequestData": "x-clerk-request-data",
"ClerkUrl": "x-clerk-clerk-url",
"CloudFrontForwardedProto": "cloudfront-forwarded-proto",
"ContentType": "content-type",
diff --git a/packages/nextjs/src/app-router/server/auth.ts b/packages/nextjs/src/app-router/server/auth.ts
index f0f66533ec..97302ea3d7 100644
--- a/packages/nextjs/src/app-router/server/auth.ts
+++ b/packages/nextjs/src/app-router/server/auth.ts
@@ -8,7 +8,7 @@ import { createGetAuth } from '../../server/createGetAuth';
import { authAuthHeaderMissing } from '../../server/errors';
import type { AuthProtect } from '../../server/protect';
import { createProtect } from '../../server/protect';
-import { getAuthKeyFromRequest } from '../../server/utils';
+import { decryptClerkRequestData, getAuthKeyFromRequest, getHeader } from '../../server/utils';
import { buildRequestLike } from './utils';
type Auth = AuthObject & { protect: AuthProtect; redirectToSignIn: RedirectFun> };
@@ -28,15 +28,16 @@ export const auth = (): Auth => {
clerkRequest.clerkUrl.searchParams.get(constants.QueryParameters.DevBrowser) ||
clerkRequest.cookies.get(constants.Cookies.DevBrowser);
+ const encryptedRequestData = getHeader(request, constants.Headers.ClerkRequestData);
+ const decryptedRequestData = decryptClerkRequestData(encryptedRequestData);
+
return createRedirect({
redirectAdapter: redirect,
devBrowserToken: devBrowserToken,
baseUrl: clerkRequest.clerkUrl.toString(),
- // TODO: Support runtime-value configuration of these options
- // via setting and reading headers from clerkMiddleware
- publishableKey: PUBLISHABLE_KEY,
- signInUrl: SIGN_IN_URL,
- signUpUrl: SIGN_UP_URL,
+ publishableKey: decryptedRequestData.publishableKey || PUBLISHABLE_KEY,
+ signInUrl: decryptedRequestData.signInUrl || SIGN_IN_URL,
+ signUpUrl: decryptedRequestData.signUpUrl || SIGN_UP_URL,
}).redirectToSignIn({
returnBackUrl: opts.returnBackUrl === null ? '' : opts.returnBackUrl || clerkUrl?.toString(),
});
diff --git a/packages/nextjs/src/app-router/server/currentUser.ts b/packages/nextjs/src/app-router/server/currentUser.ts
index ae3a2090f8..4d7cb2d7be 100644
--- a/packages/nextjs/src/app-router/server/currentUser.ts
+++ b/packages/nextjs/src/app-router/server/currentUser.ts
@@ -9,5 +9,5 @@ export async function currentUser(): Promise {
return null;
}
- return clerkClient.users.getUser(userId);
+ return clerkClient().users.getUser(userId);
}
diff --git a/packages/nextjs/src/app-router/server/utils.ts b/packages/nextjs/src/app-router/server/utils.ts
index e8375c319b..5d732c1b9e 100644
--- a/packages/nextjs/src/app-router/server/utils.ts
+++ b/packages/nextjs/src/app-router/server/utils.ts
@@ -1,6 +1,6 @@
import { NextRequest } from 'next/server';
-const isPrerenderingBailout = (e: unknown) => {
+export const isPrerenderingBailout = (e: unknown) => {
if (!(e instanceof Error) || !('message' in e)) {
return false;
}
diff --git a/packages/nextjs/src/server/__tests__/clerkClient.test.ts b/packages/nextjs/src/server/__tests__/clerkClient.test.ts
index 1ceedefc0f..58b6e883dd 100644
--- a/packages/nextjs/src/server/__tests__/clerkClient.test.ts
+++ b/packages/nextjs/src/server/__tests__/clerkClient.test.ts
@@ -4,7 +4,7 @@ import { clerkClient } from '../clerkClient';
describe('clerkClient', () => {
it('should pass version package to userAgent', async () => {
- await clerkClient.users.getUser('user_test');
+ await clerkClient().users.getUser('user_test');
expect(global.fetch).toBeCalled();
expect((global.fetch as any).mock.calls[0][1].headers).toMatchObject({
diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts
index 8996175599..d793c9493e 100644
--- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts
+++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts
@@ -14,17 +14,20 @@ const authenticateRequestMock = jest.fn().mockResolvedValue({
jest.mock('../clerkClient', () => {
return {
- clerkClient: {
+ clerkClient: () => ({
authenticateRequest: authenticateRequestMock,
telemetry: { record: jest.fn() },
- },
+ }),
};
});
// used to assert the mock
+import assert from 'assert';
+
import { clerkClient } from '../clerkClient';
import { clerkMiddleware } from '../clerkMiddleware';
import { createRouteMatcher } from '../routeMatcher';
+import { decryptClerkRequestData } from '../utils';
/**
* Disable console warnings about config matchers
@@ -44,6 +47,7 @@ jest.mock('../constants', () => {
return {
PUBLISHABLE_KEY: 'pk_test_Y2xlcmsuaW5jbHVkZWQua2F0eWRpZC05Mi5sY2wuZGV2JA',
SECRET_KEY: 'sk_test_xxxxxxxxxxxxxxxxxx',
+ ENCRYPTION_KEY: 'encryption-key',
};
});
@@ -216,6 +220,25 @@ describe('clerkMiddleware(params)', () => {
expect(signInResp?.headers.get('a-custom-header')).toEqual('1');
});
+ it('propagates middleware dynamic keys to the next request', async () => {
+ const options = {
+ secretKey: 'sk_test_xxxxxxxxxxxxxxxxxx',
+ publishableKey: 'pk_test_xxxxxxxxxxxxx',
+ signInUrl: '/foo',
+ signUpUrl: '/bar',
+ };
+ const resp = await clerkMiddleware(options)(mockRequest({ url: '/sign-in' }), {} as NextFetchEvent);
+ expect(resp?.status).toEqual(200);
+
+ const requestData = resp?.headers.get('x-middleware-request-x-clerk-request-data');
+ assert.ok(requestData);
+
+ const decryptedData = decryptClerkRequestData(requestData);
+
+ expect(resp?.headers.get('x-middleware-request-x-clerk-request-data')).toBeDefined();
+ expect(decryptedData).toEqual(options);
+ });
+
describe('auth().redirectToSignIn()', () => {
it('redirects to sign-in url when redirectToSignIn is called and the request is a page request', async () => {
const req = mockRequest({
@@ -230,7 +253,7 @@ describe('clerkMiddleware(params)', () => {
expect(resp?.status).toEqual(307);
expect(resp?.headers.get('location')).toContain('sign-in');
- expect(clerkClient.authenticateRequest).toBeCalled();
+ expect(clerkClient().authenticateRequest).toBeCalled();
});
it('redirects to sign-in url when redirectToSignIn is called with the correct returnBackUrl', async () => {
@@ -247,7 +270,7 @@ describe('clerkMiddleware(params)', () => {
expect(resp?.status).toEqual(307);
expect(resp?.headers.get('location')).toContain('sign-in');
expect(new URL(resp!.headers.get('location')!).searchParams.get('redirect_url')).toContain('/protected');
- expect(clerkClient.authenticateRequest).toBeCalled();
+ expect(clerkClient().authenticateRequest).toBeCalled();
});
it('redirects to sign-in url with redirect_url set to the provided returnBackUrl param', async () => {
@@ -266,7 +289,7 @@ describe('clerkMiddleware(params)', () => {
expect(new URL(resp!.headers.get('location')!).searchParams.get('redirect_url')).toEqual(
'https://www.clerk.com/hello',
);
- expect(clerkClient.authenticateRequest).toBeCalled();
+ expect(clerkClient().authenticateRequest).toBeCalled();
});
it('redirects to sign-in url without a redirect_url when returnBackUrl is null', async () => {
@@ -283,7 +306,7 @@ describe('clerkMiddleware(params)', () => {
expect(resp?.status).toEqual(307);
expect(resp?.headers.get('location')).toContain('sign-in');
expect(new URL(resp!.headers.get('location')!).searchParams.get('redirect_url')).toBeNull();
- expect(clerkClient.authenticateRequest).toBeCalled();
+ expect(clerkClient().authenticateRequest).toBeCalled();
});
});
@@ -308,7 +331,7 @@ describe('clerkMiddleware(params)', () => {
expect(resp?.status).toEqual(307);
expect(resp?.headers.get('location')).toContain('sign-in');
- expect(clerkClient.authenticateRequest).toBeCalled();
+ expect(clerkClient().authenticateRequest).toBeCalled();
});
it('does not redirect to sign-in url when protect is called, the user is signed in and the request is a page request', async () => {
@@ -331,7 +354,7 @@ describe('clerkMiddleware(params)', () => {
expect(resp?.status).toEqual(200);
expect(resp?.headers.get('location')).toBeFalsy();
- expect(clerkClient.authenticateRequest).toBeCalled();
+ expect(clerkClient().authenticateRequest).toBeCalled();
});
it('throws a not found error when protect is called, the user is signed out, and is not a page request', async () => {
@@ -354,7 +377,7 @@ describe('clerkMiddleware(params)', () => {
expect(resp?.status).toEqual(200);
expect(resp?.headers.get(constants.Headers.AuthReason)).toContain('protect-rewrite');
- expect(clerkClient.authenticateRequest).toBeCalled();
+ expect(clerkClient().authenticateRequest).toBeCalled();
});
it('throws a not found error when protect is called with RBAC params the user does not fulfill, and is a page request', async () => {
@@ -377,7 +400,7 @@ describe('clerkMiddleware(params)', () => {
expect(resp?.status).toEqual(200);
expect(resp?.headers.get(constants.Headers.AuthReason)).toContain('protect-rewrite');
- expect(clerkClient.authenticateRequest).toBeCalled();
+ expect(clerkClient().authenticateRequest).toBeCalled();
});
it('redirects to unauthenticatedUrl when protect is called with the redirectUrl param, the user is signed out, and is a page request', async () => {
@@ -401,7 +424,7 @@ describe('clerkMiddleware(params)', () => {
expect(resp?.status).toEqual(307);
expect(resp?.headers.get('location')).toEqual('https://www.clerk.com/hello');
expect(resp?.headers.get(constants.Headers.ClerkRedirectTo)).toEqual('true');
- expect(clerkClient.authenticateRequest).toBeCalled();
+ expect(clerkClient().authenticateRequest).toBeCalled();
});
it('redirects to unauthorizedUrl when protect is called with the redirectUrl param, the user does not fulfill the RBAC params, and is a page request', async () => {
@@ -431,7 +454,7 @@ describe('clerkMiddleware(params)', () => {
expect(resp?.status).toEqual(307);
expect(resp?.headers.get('location')).toEqual('https://www.clerk.com/discover');
expect(resp?.headers.get(constants.Headers.ClerkRedirectTo)).toEqual('true');
- expect(clerkClient.authenticateRequest).toBeCalled();
+ expect(clerkClient().authenticateRequest).toBeCalled();
});
});
@@ -456,7 +479,7 @@ describe('clerkMiddleware(params)', () => {
expect(resp?.status).toEqual(307);
expect(resp?.headers.get('location')).toContain('sign-in');
- expect(clerkClient.authenticateRequest).toBeCalled();
+ expect(clerkClient().authenticateRequest).toBeCalled();
});
it('forwards headers from authenticateRequest when auth().protect() is called', async () => {
@@ -484,7 +507,7 @@ describe('clerkMiddleware(params)', () => {
expect(resp?.headers.get('X-Clerk-Auth')).toEqual('1');
expect(resp?.headers.get('Set-Cookie')).toEqual('session=;');
expect(resp?.headers.get('location')).toContain('sign-in');
- expect(clerkClient.authenticateRequest).toBeCalled();
+ expect(clerkClient().authenticateRequest).toBeCalled();
});
it('redirects to unauthenticatedUrl when protect is called with the unauthenticatedUrl param, the user is signed out, and is a page request', async () => {
@@ -511,7 +534,7 @@ describe('clerkMiddleware(params)', () => {
expect(resp?.status).toEqual(307);
expect(resp?.headers.get('location')).toContain('https://www.clerk.com/unauthenticatedUrl');
expect(resp?.headers.get(constants.Headers.ClerkRedirectTo)).toEqual('true');
- expect(clerkClient.authenticateRequest).toBeCalled();
+ expect(clerkClient().authenticateRequest).toBeCalled();
});
it('redirects to unauthorizedUrl when protect is called with the unauthorizedUrl param, the user is signed in but does not have permissions, and is a page request', async () => {
@@ -541,7 +564,7 @@ describe('clerkMiddleware(params)', () => {
expect(resp?.status).toEqual(307);
expect(resp?.headers.get('location')).toContain('https://www.clerk.com/unauthorizedUrl');
expect(resp?.headers.get(constants.Headers.ClerkRedirectTo)).toEqual('true');
- expect(clerkClient.authenticateRequest).toBeCalled();
+ expect(clerkClient().authenticateRequest).toBeCalled();
});
});
});
@@ -569,7 +592,7 @@ describe('Dev Browser JWT when redirecting to cross origin for page requests', f
expect(resp?.headers.get('location')).toEqual(
'https://accounts.included.katydid-92.lcl.dev/sign-in?redirect_url=https%3A%2F%2Fwww.clerk.com%2Fprotected',
);
- expect(clerkClient.authenticateRequest).toBeCalled();
+ expect(clerkClient().authenticateRequest).toBeCalled();
});
it('appends the Dev Browser JWT to the search when cookie __clerk_db_jwt exists and location is an Account Portal URL', async () => {
@@ -593,7 +616,7 @@ describe('Dev Browser JWT when redirecting to cross origin for page requests', f
expect(resp?.headers.get('location')).toEqual(
'https://accounts.included.katydid-92.lcl.dev/sign-in?redirect_url=https%3A%2F%2Fwww.clerk.com%2Fprotected&__clerk_db_jwt=test_jwt',
);
- expect(clerkClient.authenticateRequest).toBeCalled();
+ expect(clerkClient().authenticateRequest).toBeCalled();
});
it('does NOT append the Dev Browser JWT if x-clerk-redirect-to header is not set (user-returned redirect)', async () => {
@@ -619,6 +642,6 @@ describe('Dev Browser JWT when redirecting to cross origin for page requests', f
expect(resp?.headers.get('location')).toEqual(
'https://accounts.included.katydid-92.lcl.dev/sign-in?redirect_url=https%3A%2F%2Fwww.clerk.com%2Fprotected',
);
- expect(clerkClient.authenticateRequest).toBeCalled();
+ expect(clerkClient().authenticateRequest).toBeCalled();
});
});
diff --git a/packages/nextjs/src/server/authMiddleware.ts b/packages/nextjs/src/server/authMiddleware.ts
index 1b560dcc0c..e9803a0322 100644
--- a/packages/nextjs/src/server/authMiddleware.ts
+++ b/packages/nextjs/src/server/authMiddleware.ts
@@ -219,7 +219,7 @@ const authMiddleware: AuthMiddleware = (...args: unknown[]) => {
logger.debug(`Added ${constants.Headers.EnableDebug} on request`);
}
- const result = decorateRequest(clerkRequest, finalRes, requestState, secretKey) || NextResponse.next();
+ const result = decorateRequest(clerkRequest, finalRes, requestState, { secretKey }) || NextResponse.next();
if (requestState.headers) {
requestState.headers.forEach((value, key) => {
diff --git a/packages/nextjs/src/server/buildClerkProps.ts b/packages/nextjs/src/server/buildClerkProps.ts
index 0248a46123..ffffa401d8 100644
--- a/packages/nextjs/src/server/buildClerkProps.ts
+++ b/packages/nextjs/src/server/buildClerkProps.ts
@@ -1,6 +1,7 @@
import type { Organization, Session, User } from '@clerk/backend';
import {
AuthStatus,
+ constants,
makeAuthObjectSerializable,
signedInAuthObject,
signedOutAuthObject,
@@ -10,7 +11,7 @@ import { decodeJwt } from '@clerk/backend/jwt';
import { API_URL, API_VERSION, SECRET_KEY } from './constants';
import type { RequestLike } from './types';
-import { getAuthKeyFromRequest, injectSSRStateIntoObject } from './utils';
+import { decryptClerkRequestData, getAuthKeyFromRequest, getHeader, injectSSRStateIntoObject } from './utils';
type BuildClerkPropsInitState = { user?: User | null; session?: Session | null; organization?: Organization | null };
@@ -38,8 +39,11 @@ export const buildClerkProps: BuildClerkProps = (req, initState = {}) => {
const authMessage = getAuthKeyFromRequest(req, 'AuthMessage');
const authReason = getAuthKeyFromRequest(req, 'AuthReason');
+ const encryptedRequestData = getHeader(req, constants.Headers.ClerkRequestData);
+ const decryptedRequestData = decryptClerkRequestData(encryptedRequestData);
+
const options = {
- secretKey: SECRET_KEY,
+ secretKey: decryptedRequestData.secretKey || SECRET_KEY,
apiUrl: API_URL,
apiVersion: API_VERSION,
authStatus,
diff --git a/packages/nextjs/src/server/clerkClient.ts b/packages/nextjs/src/server/clerkClient.ts
index 41b6559b5a..62d7a19cfd 100644
--- a/packages/nextjs/src/server/clerkClient.ts
+++ b/packages/nextjs/src/server/clerkClient.ts
@@ -1,5 +1,10 @@
+import type { ClerkClient } from '@clerk/backend';
import { createClerkClient } from '@clerk/backend';
+import { constants } from '@clerk/backend/internal';
+import { deprecated } from '@clerk/shared/deprecated';
+import { buildRequestLike, isPrerenderingBailout } from '../app-router/server/utils';
+import { clerkMiddlewareRequestDataStore } from './clerkMiddleware';
import {
API_URL,
API_VERSION,
@@ -12,8 +17,9 @@ import {
TELEMETRY_DEBUG,
TELEMETRY_DISABLED,
} from './constants';
+import { decryptClerkRequestData, getHeader } from './utils';
-const clerkClient = createClerkClient({
+const clerkClientDefaultOptions = {
secretKey: SECRET_KEY,
publishableKey: PUBLISHABLE_KEY,
apiUrl: API_URL,
@@ -27,6 +33,54 @@ const clerkClient = createClerkClient({
disabled: TELEMETRY_DISABLED,
debug: TELEMETRY_DEBUG,
},
-});
+};
+
+const createClerkClientWithOptions: typeof createClerkClient = options =>
+ createClerkClient({ ...clerkClientDefaultOptions, ...options });
+
+/**
+ * @deprecated
+ * This singleton is deprecated and will be removed in a future release. Please use `clerkClient()` as a function instead.
+ */
+const clerkClientSingleton = createClerkClient(clerkClientDefaultOptions);
+
+/**
+ * Constructs a BAPI client that accesses request data within the runtime.
+ * Necessary if middleware dynamic keys are used.
+ */
+const clerkClientForRequest = () => {
+ let requestData;
+
+ try {
+ const request = buildRequestLike();
+ const encryptedRequestData = getHeader(request, constants.Headers.ClerkRequestData);
+ requestData = decryptClerkRequestData(encryptedRequestData);
+ } catch (err) {
+ if (err && isPrerenderingBailout(err)) {
+ throw err;
+ }
+ }
+
+ // Fallbacks between options from middleware runtime and `NextRequest` from application server
+ const options = clerkMiddlewareRequestDataStore.getStore() ?? requestData;
+ if (options?.secretKey || options?.publishableKey) {
+ return createClerkClientWithOptions(options);
+ }
+
+ return clerkClientSingleton;
+};
+
+interface ClerkClientExport extends ClerkClient {
+ (): ClerkClient;
+}
+
+// TODO SDK-1839 - Remove `clerkClient` singleton in the next major version of `@clerk/nextjs`
+const clerkClient = new Proxy(Object.assign(clerkClientForRequest, clerkClientSingleton), {
+ get(target, prop: string, receiver) {
+ deprecated('clerkClient singleton', 'Use `clerkClient()` as a function instead.');
+
+ return Reflect.get(target, prop, receiver);
+ },
+}) as ClerkClientExport;
export { clerkClient };
diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts
index 7c11f52038..586df62c11 100644
--- a/packages/nextjs/src/server/clerkMiddleware.ts
+++ b/packages/nextjs/src/server/clerkMiddleware.ts
@@ -7,6 +7,7 @@ import type {
} from '@clerk/backend/internal';
import { AuthStatus, constants, createClerkRequest, createRedirect } from '@clerk/backend/internal';
import { eventMethodCalled } from '@clerk/shared/telemetry';
+import { AsyncLocalStorage } from 'async_hooks';
import type { NextMiddleware } from 'next/server';
import { NextResponse } from 'next/server';
@@ -69,6 +70,8 @@ interface ClerkMiddleware {
(request: NextMiddlewareRequestParam, event: NextMiddlewareEvtParam): NextMiddlewareReturn;
}
+export const clerkMiddlewareRequestDataStore = new AsyncLocalStorage>();
+
export const clerkMiddleware: ClerkMiddleware = withLogger('clerkMiddleware', logger => (...args: unknown[]): any => {
const [request, event] = parseRequestAndEvent(args);
const [handler, params] = parseHandlerAndOptions(args);
@@ -91,82 +94,87 @@ export const clerkMiddleware: ClerkMiddleware = withLogger('clerkMiddleware', lo
signUpUrl,
};
- clerkClient.telemetry.record(
- eventMethodCalled('clerkMiddleware', {
- handler: Boolean(handler),
- satellite: Boolean(options.isSatellite),
- proxy: Boolean(options.proxyUrl),
- }),
- );
-
- const nextMiddleware: NextMiddleware = async (request, event) => {
- const clerkRequest = createClerkRequest(request);
- logger.debug('options', options);
- logger.debug('url', () => clerkRequest.toJSON());
-
- const requestState = await clerkClient.authenticateRequest(
- clerkRequest,
- createAuthenticateRequestOptions(clerkRequest, options),
+ return clerkMiddlewareRequestDataStore.run(options, () => {
+ clerkClient().telemetry.record(
+ eventMethodCalled('clerkMiddleware', {
+ handler: Boolean(handler),
+ satellite: Boolean(options.isSatellite),
+ proxy: Boolean(options.proxyUrl),
+ }),
);
- logger.debug('requestState', () => ({
- status: requestState.status,
- headers: JSON.stringify(Object.fromEntries(requestState.headers)),
- reason: requestState.reason,
- }));
-
- const locationHeader = requestState.headers.get(constants.Headers.Location);
- if (locationHeader) {
- return new Response(null, { status: 307, headers: requestState.headers });
- } else if (requestState.status === AuthStatus.Handshake) {
- throw new Error('Clerk: handshake status without redirect');
- }
-
- const authObject = requestState.toAuth();
- logger.debug('auth', () => ({ auth: authObject, debug: authObject.debug() }));
-
- const redirectToSignIn = createMiddlewareRedirectToSignIn(clerkRequest);
- const protect = createMiddlewareProtect(clerkRequest, authObject, redirectToSignIn);
- const authObjWithMethods: ClerkMiddlewareAuthObject = Object.assign(authObject, { protect, redirectToSignIn });
-
- let handlerResult: Response = NextResponse.next();
- try {
- handlerResult = (await handler?.(() => authObjWithMethods, request, event)) || handlerResult;
- } catch (e: any) {
- handlerResult = handleControlFlowErrors(e, clerkRequest, requestState);
- }
+ const nextMiddleware: NextMiddleware = async (request, event) => {
+ const clerkRequest = createClerkRequest(request);
+ logger.debug('options', options);
+ logger.debug('url', () => clerkRequest.toJSON());
- // TODO @nikos: we need to make this more generic
- // and move the logic in clerk/backend
- if (requestState.headers) {
- requestState.headers.forEach((value, key) => {
- handlerResult.headers.append(key, value);
- });
- }
+ const requestState = await clerkClient().authenticateRequest(
+ clerkRequest,
+ createAuthenticateRequestOptions(clerkRequest, options),
+ );
- if (isRedirect(handlerResult)) {
- logger.debug('handlerResult is redirect');
- return serverRedirectWithAuth(clerkRequest, handlerResult, options);
- }
+ logger.debug('requestState', () => ({
+ status: requestState.status,
+ headers: JSON.stringify(Object.fromEntries(requestState.headers)),
+ reason: requestState.reason,
+ }));
+
+ const locationHeader = requestState.headers.get(constants.Headers.Location);
+ if (locationHeader) {
+ return new Response(null, { status: 307, headers: requestState.headers });
+ } else if (requestState.status === AuthStatus.Handshake) {
+ throw new Error('Clerk: handshake status without redirect');
+ }
+
+ const authObject = requestState.toAuth();
+ logger.debug('auth', () => ({ auth: authObject, debug: authObject.debug() }));
+
+ const redirectToSignIn = createMiddlewareRedirectToSignIn(clerkRequest);
+ const protect = createMiddlewareProtect(clerkRequest, authObject, redirectToSignIn);
+ const authObjWithMethods: ClerkMiddlewareAuthObject = Object.assign(authObject, { protect, redirectToSignIn });
+
+ let handlerResult: Response = NextResponse.next();
+ try {
+ const userHandlerResult = await clerkMiddlewareRequestDataStore.run(options, async () =>
+ handler?.(() => authObjWithMethods, request, event),
+ );
+ handlerResult = userHandlerResult || handlerResult;
+ } catch (e: any) {
+ handlerResult = handleControlFlowErrors(e, clerkRequest, requestState);
+ }
+
+ // TODO @nikos: we need to make this more generic
+ // and move the logic in clerk/backend
+ if (requestState.headers) {
+ requestState.headers.forEach((value, key) => {
+ handlerResult.headers.append(key, value);
+ });
+ }
+
+ if (isRedirect(handlerResult)) {
+ logger.debug('handlerResult is redirect');
+ return serverRedirectWithAuth(clerkRequest, handlerResult, options);
+ }
+
+ if (options.debug) {
+ setRequestHeadersOnNextResponse(handlerResult, clerkRequest, { [constants.Headers.EnableDebug]: 'true' });
+ }
+
+ decorateRequest(clerkRequest, handlerResult, requestState, params);
+
+ return handlerResult;
+ };
- if (options.debug) {
- setRequestHeadersOnNextResponse(handlerResult, clerkRequest, { [constants.Headers.EnableDebug]: 'true' });
+ // If we have a request and event, we're being called as a middleware directly
+ // eg, export default clerkMiddleware;
+ if (request && event) {
+ return nextMiddleware(request, event);
}
- decorateRequest(clerkRequest, handlerResult, requestState, options.secretKey);
-
- return handlerResult;
- };
-
- // If we have a request and event, we're being called as a middleware directly
- // eg, export default clerkMiddleware;
- if (request && event) {
- return nextMiddleware(request, event);
- }
-
- // Otherwise, return a middleware that can be called with a request and event
- // eg, export default clerkMiddleware(auth => { ... });
- return nextMiddleware;
+ // Otherwise, return a middleware that can be called with a request and event
+ // eg, export default clerkMiddleware(auth => { ... });
+ return nextMiddleware;
+ });
});
const parseRequestAndEvent = (args: unknown[]) => {
diff --git a/packages/nextjs/src/server/constants.ts b/packages/nextjs/src/server/constants.ts
index 38749e2451..36ec73b95e 100644
--- a/packages/nextjs/src/server/constants.ts
+++ b/packages/nextjs/src/server/constants.ts
@@ -6,6 +6,7 @@ export const CLERK_JS_URL = process.env.NEXT_PUBLIC_CLERK_JS_URL || '';
export const API_VERSION = process.env.CLERK_API_VERSION || 'v1';
export const SECRET_KEY = process.env.CLERK_SECRET_KEY || '';
export const PUBLISHABLE_KEY = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY || '';
+export const ENCRYPTION_KEY = process.env.CLERK_ENCRYPTION_KEY || '';
export const API_URL = process.env.CLERK_API_URL || apiUrlFromPublishableKey(PUBLISHABLE_KEY);
export const DOMAIN = process.env.NEXT_PUBLIC_CLERK_DOMAIN || '';
export const PROXY_URL = process.env.NEXT_PUBLIC_CLERK_PROXY_URL || '';
diff --git a/packages/nextjs/src/server/createGetAuth.ts b/packages/nextjs/src/server/createGetAuth.ts
index 207c2e9cb8..03fe44252f 100644
--- a/packages/nextjs/src/server/createGetAuth.ts
+++ b/packages/nextjs/src/server/createGetAuth.ts
@@ -6,7 +6,7 @@ import { withLogger } from '../utils/debugLogger';
import { API_URL, API_VERSION, SECRET_KEY } from './constants';
import { getAuthAuthHeaderMissing } from './errors';
import type { RequestLike } from './types';
-import { assertTokenSignature, getAuthKeyFromRequest, getCookie, getHeader } from './utils';
+import { assertTokenSignature, decryptClerkRequestData, getAuthKeyFromRequest, getCookie, getHeader } from './utils';
export const createGetAuth = ({
noAuthStatusMessage,
@@ -35,12 +35,15 @@ export const createGetAuth = ({
throw new Error(noAuthStatusMessage);
}
+ const encryptedRequestData = getHeader(req, constants.Headers.ClerkRequestData);
+ const decryptedRequestData = decryptClerkRequestData(encryptedRequestData);
+
const options = {
authStatus,
apiUrl: API_URL,
apiVersion: API_VERSION,
authMessage,
- secretKey: opts?.secretKey || SECRET_KEY,
+ secretKey: opts?.secretKey || decryptedRequestData.secretKey || SECRET_KEY,
authReason,
};
diff --git a/packages/nextjs/src/server/errors.ts b/packages/nextjs/src/server/errors.ts
index 8e23e9d5dd..eba3589e23 100644
--- a/packages/nextjs/src/server/errors.ts
+++ b/packages/nextjs/src/server/errors.ts
@@ -97,3 +97,5 @@ For additional information about middleware, please visit https://clerk.com/docs
};
export const authSignatureInvalid = `Clerk: Unable to verify request, this usually means the Clerk middleware did not run. Ensure Clerk's middleware is properly integrated and matches the current route. For more information, see: https://clerk.com/docs/nextjs/middleware. (code=auth_signature_invalid)`;
+
+export const encryptionKeyInvalid = `Clerk: Unable to decrypt request data, this usually means the encryption key is invalid. Ensure the encryption key is properly set. For more information, see: https://clerk.com/docs/references/nextjs/clerk-middleware#dynamic-keys. (code=encryption_key_invalid)`;
diff --git a/packages/nextjs/src/server/utils.ts b/packages/nextjs/src/server/utils.ts
index 7d6eb6b779..8ec537538b 100644
--- a/packages/nextjs/src/server/utils.ts
+++ b/packages/nextjs/src/server/utils.ts
@@ -1,15 +1,19 @@
import type { AuthenticateRequestOptions, ClerkRequest, RequestState } from '@clerk/backend/internal';
import { constants } from '@clerk/backend/internal';
+import { logger } from '@clerk/shared';
import { handleValueOrFn } from '@clerk/shared/handleValueOrFn';
import { isDevelopmentFromSecretKey } from '@clerk/shared/keys';
import { isHttpOrHttps } from '@clerk/shared/proxy';
+import AES from 'crypto-js/aes';
+import encUtf8 from 'crypto-js/enc-utf8';
import hmacSHA1 from 'crypto-js/hmac-sha1';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { constants as nextConstants } from '../constants';
-import { DOMAIN, IS_SATELLITE, PROXY_URL, SECRET_KEY, SIGN_IN_URL } from './constants';
-import { authSignatureInvalid, missingDomainAndProxy, missingSignInUrlInDev } from './errors';
+import { DOMAIN, ENCRYPTION_KEY, IS_SATELLITE, PROXY_URL, SECRET_KEY, SIGN_IN_URL } from './constants';
+import { authSignatureInvalid, encryptionKeyInvalid, missingDomainAndProxy, missingSignInUrlInDev } from './errors';
+import { errorThrower } from './errorThrower';
import type { RequestLike } from './types';
export function setCustomAttributeOnRequest(req: RequestLike, key: string, value: string): void {
@@ -113,7 +117,7 @@ export function decorateRequest(
req: ClerkRequest,
res: Response,
requestState: RequestState,
- secretKey: string,
+ requestData?: AuthenticateRequestOptions,
): Response {
const { reason, message, status, token } = requestState;
// pass-through case, convert to next()
@@ -148,13 +152,16 @@ export function decorateRequest(
}
if (rewriteURL) {
+ const clerkRequestData = encryptClerkRequestData(requestData);
+
setRequestHeadersOnNextResponse(res, req, {
[constants.Headers.AuthStatus]: status,
[constants.Headers.AuthToken]: token || '',
- [constants.Headers.AuthSignature]: token ? createTokenSignature(token, secretKey) : '',
+ [constants.Headers.AuthSignature]: token ? createTokenSignature(token, requestData?.secretKey ?? SECRET_KEY) : '',
[constants.Headers.AuthMessage]: message || '',
[constants.Headers.AuthReason]: reason || '',
[constants.Headers.ClerkUrl]: req.clerkUrl.toString(),
+ ...(clerkRequestData ? { [constants.Headers.ClerkRequestData]: clerkRequestData } : {}),
});
res.headers.set(nextConstants.Headers.NextRewrite, rewriteURL.href);
}
@@ -234,3 +241,47 @@ export function assertTokenSignature(token: string, key: string, signature?: str
throw new Error(authSignatureInvalid);
}
}
+
+/**
+ * Encrypt request data propagated between server requests.
+ * @internal
+ **/
+export function encryptClerkRequestData(requestData?: Partial) {
+ if (!requestData || !Object.values(requestData).length) {
+ return;
+ }
+
+ if (requestData.secretKey && !ENCRYPTION_KEY) {
+ // TODO SDK-1833: change this to an error in the next major version of `@clerk/nextjs`
+ logger.warnOnce(
+ 'Clerk: Missing `CLERK_ENCRYPTION_KEY`. Required for propagating `secretKey` middleware option. See docs: https://clerk.com/docs/references/nextjs/clerk-middleware#dynamic-keys',
+ );
+
+ return;
+ }
+
+ return AES.encrypt(
+ JSON.stringify(requestData),
+ ENCRYPTION_KEY || assertKey(SECRET_KEY, () => errorThrower.throwMissingSecretKeyError()),
+ ).toString();
+}
+
+/**
+ * Decrypt request data propagated between server requests.
+ * @internal
+ */
+export function decryptClerkRequestData(
+ encryptedRequestData?: string | undefined | null,
+): Partial {
+ if (!encryptedRequestData) {
+ return {};
+ }
+
+ try {
+ const decryptedBytes = AES.decrypt(encryptedRequestData, ENCRYPTION_KEY || SECRET_KEY);
+ const encoded = decryptedBytes.toString(encUtf8);
+ return JSON.parse(encoded);
+ } catch (err) {
+ throw new Error(encryptionKeyInvalid);
+ }
+}