From b18f709b185afc3a595a8067bb11551782d0d59b Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Tue, 18 Aug 2026 11:15:44 -0700 Subject: [PATCH 1/3] feat(nextjs): throw missing-env error instead of keyless bootstrap Squashed from the original four commits of this PR (bootstrap removal, import sort, adversarial-review findings, non-interactive CLI wording) during the simple-first stack reorder. Co-Authored-By: Claude Fable 5 --- .../keyless-bootstrap-state-deprecated.md | 5 + .changeset/keyless-cli-init-error.md | 5 + .../tests/next-middleware-keyless.test.ts | 10 +- .../tests/next-quickstart-keyless.test.ts | 82 ++++-------- packages/backend/src/tokens/authStatus.ts | 2 + .../src/app-router/client/ClerkProvider.tsx | 16 +-- .../app-router/client/keyless-cookie-sync.tsx | 27 ---- .../client/keyless-creator-reader.tsx | 32 ----- .../nextjs/src/app-router/keyless-actions.ts | 85 ------------ .../src/app-router/server/ClerkProvider.tsx | 4 + .../app-router/server/keyless-provider.tsx | 35 +---- .../__tests__/clerkMiddlewareKeyless.test.ts | 54 ++++++++ packages/nextjs/src/server/clerkMiddleware.ts | 124 ++---------------- 13 files changed, 118 insertions(+), 363 deletions(-) create mode 100644 .changeset/keyless-bootstrap-state-deprecated.md create mode 100644 .changeset/keyless-cli-init-error.md delete mode 100644 packages/nextjs/src/app-router/client/keyless-cookie-sync.tsx delete mode 100644 packages/nextjs/src/app-router/client/keyless-creator-reader.tsx create mode 100644 packages/nextjs/src/server/__tests__/clerkMiddlewareKeyless.test.ts diff --git a/.changeset/keyless-bootstrap-state-deprecated.md b/.changeset/keyless-bootstrap-state-deprecated.md new file mode 100644 index 00000000000..40c2859a47b --- /dev/null +++ b/.changeset/keyless-bootstrap-state-deprecated.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': patch +--- + +Mark the internal `createBootstrapSignedOutState` as deprecated. It is no longer used by `@clerk/nextjs` and is kept only for older published SDK versions. diff --git a/.changeset/keyless-cli-init-error.md b/.changeset/keyless-cli-init-error.md new file mode 100644 index 00000000000..14266760846 --- /dev/null +++ b/.changeset/keyless-cli-init-error.md @@ -0,0 +1,5 @@ +--- +'@clerk/nextjs': minor +--- + +In development, missing Clerk keys no longer activate keyless mode. When `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY` are not set, the SDK now throws an error directing you to run `npx clerk@latest init`, which provisions a Clerk application and writes the keys to `.env.local`. Keyless credentials stored in the development keyless cookie are no longer read. Existing apps with configured or claimed keys are unaffected. diff --git a/integration/tests/next-middleware-keyless.test.ts b/integration/tests/next-middleware-keyless.test.ts index fa9b2c29df9..76f33cee9bc 100644 --- a/integration/tests/next-middleware-keyless.test.ts +++ b/integration/tests/next-middleware-keyless.test.ts @@ -27,9 +27,11 @@ test.describe('Keyless mode | middleware authorization @nextjs', () => { await app.teardown(); }); - test('auth.protect() in middleware redirects to sign-in during keyless bootstrap', async ({ page }) => { - await page.goto(`${app.serverUrl}/protected`); - await page.waitForURL(/\/sign-in/); - await expect(page.getByTestId('protected')).not.toBeVisible(); + test('requests without keys fail with the missing env vars error instead of keyless bootstrap', async ({ page }) => { + const response = await page.goto(`${app.serverUrl}/protected`); + expect(response?.status()).toBe(500); + const content = await page.content(); + expect(content).toContain('Missing publishableKey'); + expect(content).toContain('npx clerk@latest init'); }); }); diff --git a/integration/tests/next-quickstart-keyless.test.ts b/integration/tests/next-quickstart-keyless.test.ts index c57a5d805b3..1c92058845e 100644 --- a/integration/tests/next-quickstart-keyless.test.ts +++ b/integration/tests/next-quickstart-keyless.test.ts @@ -1,9 +1,12 @@ +import * as path from 'node:path'; + import { expect, test } from '@playwright/test'; import type { Application } from '../models/application'; import { appConfigs } from '../presets'; +import { fs } from '../scripts'; import { createTestUtils } from '../testUtils'; -import { mockClaimedInstanceEnvironmentCall, testToggleCollapsePopoverAndClaim } from '../testUtils/keylessHelpers'; +import { mockClaimedInstanceEnvironmentCall } from '../testUtils/keylessHelpers'; const commonSetup = appConfigs.next.appRouterQuickstart.clone(); @@ -17,15 +20,11 @@ test.describe('Keyless mode @quickstart', () => { }); let app: Application; - let dashboardUrl = 'https://dashboard.clerk.com/'; test.beforeAll(async () => { app = await commonSetup.commit(); await app.setup(); await app.withEnv(appConfigs.envs.withKeyless); - if (appConfigs.envs.withKeyless.privateVariables.get('CLERK_API_URL')?.includes('clerkstage')) { - dashboardUrl = 'https://dashboard.clerkstage.dev/'; - } await app.dev(); }); @@ -33,71 +32,44 @@ test.describe('Keyless mode @quickstart', () => { await app.teardown(); }); - test('Navigates to non-existent page (/_not-found) without a infinite redirect loop.', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - await u.page.goToAppHome(); - await u.page.waitForClerkJsLoaded(); - await u.po.expect.toBeSignedOut(); - - await u.po.keylessPopover.waitForMounted(); - - const redirectMap = new Map(); - page.on('request', request => { - // Only count GET requests since Next.js server actions are sent with POST requests. - if (request.method() === 'GET') { - const url = request.url(); - redirectMap.set(url, (redirectMap.get(url) || 0) + 1); - expect(redirectMap.get(url)).toBeLessThanOrEqual(1); - } - }); - - await u.page.goToRelative('/something'); - await u.page.waitForAppUrl('/something'); - }); - - test('Toggle collapse popover and claim.', async ({ page, context }) => { - await testToggleCollapsePopoverAndClaim({ page, context, app, dashboardUrl, framework: 'nextjs' }); - }); - - test('Lands on claimed application with missing explicit keys, expanded by default, click to get keys from dashboard.', async ({ + test('Without keys, the app fails with the missing env vars error instead of keyless bootstrap.', async ({ page, - context, }) => { - await mockClaimedInstanceEnvironmentCall(page); - const u = createTestUtils({ app, page, context }); - await u.page.goToAppHome(); - await u.page.waitForClerkJsLoaded(); - - await u.po.keylessPopover.waitForMounted(); - expect(await u.po.keylessPopover.isExpanded()).toBe(true); - await expect(u.po.keylessPopover.promptToUseClaimedKeys()).toBeVisible(); - - const href = await u.po.keylessPopover.promptToUseClaimedKeys().getAttribute('href'); - expect(href).toBeTruthy(); - expect(href).toContain(dashboardUrl); + const response = await page.goto(`${app.serverUrl}/`); + expect(response?.status()).toBe(500); + const content = await page.content(); + expect(content).toContain('Missing publishableKey'); + expect(content).toContain('npx clerk@latest init'); }); - test('Claimed application with keys inside .env, on dismiss, keyless prompt is removed.', async ({ + test('Claimed application with keys inside .env mounts the keyless prompt; on dismiss, it is removed.', async ({ page, context, }) => { - await mockClaimedInstanceEnvironmentCall(page); - const u = createTestUtils({ app, page, context }); - await u.page.goToAppHome(); - - await u.po.keylessPopover.waitForMounted(); - await expect(await u.po.keylessPopover.promptToUseClaimedKeys()).toBeVisible(); - /** - * Copy keys from `.clerk/.tmp/keyless.json to `.env` + * Seed claimed keyless state directly: the SDK no longer mints keys, so write the + * keys fixture to `.clerk/.tmp/keyless.json` and copy the matching keys into `.env`. */ + const publishableKey = appConfigs.envs.withEmailCodes.publicVariables.get('CLERK_PUBLISHABLE_KEY'); + const secretKey = appConfigs.envs.withEmailCodes.privateVariables.get('CLERK_SECRET_KEY'); + await fs.ensureDir(path.join(app.appDir, '.clerk', '.tmp')); + await fs.writeJSON(path.join(app.appDir, '.clerk', '.tmp', 'keyless.json'), { + publishableKey, + secretKey, + claimUrl: 'https://dashboard.clerk.com/apps/claim', + apiKeysUrl: 'https://dashboard.clerk.com/last-active?path=api-keys', + }); await app.keylessToEnv(); /** * wait a bit for the server to load the new env file */ await page.waitForTimeout(5_000); - await page.reload(); + await mockClaimedInstanceEnvironmentCall(page); + const u = createTestUtils({ app, page, context }); + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + await u.po.keylessPopover.waitForMounted(); await u.po.keylessPopover.promptToDismiss().click(); diff --git a/packages/backend/src/tokens/authStatus.ts b/packages/backend/src/tokens/authStatus.ts index 421c7bd61f4..358b29e0b69 100644 --- a/packages/backend/src/tokens/authStatus.ts +++ b/packages/backend/src/tokens/authStatus.ts @@ -291,6 +291,8 @@ type BootstrapSignedOutParams = { * `isSatellite` / `domain` / `proxyUrl` are carried through so that cross-origin * satellite redirects produced by `createRedirect` include the `__clerk_status=needs-sync` * marker required for the return-trip handshake. + * + * @deprecated No longer used by `@clerk/nextjs`; kept for older published SDK versions. Remove in the next major. */ export function createBootstrapSignedOutState({ signInUrl = '', diff --git a/packages/nextjs/src/app-router/client/ClerkProvider.tsx b/packages/nextjs/src/app-router/client/ClerkProvider.tsx index 109e1a38c87..fb6834585a4 100644 --- a/packages/nextjs/src/app-router/client/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/client/ClerkProvider.tsx @@ -1,12 +1,12 @@ 'use client'; import { InternalClerkProvider as ReactClerkProvider, type Ui } from '@clerk/react/internal'; import { InitialStateProvider } from '@clerk/shared/react'; -import dynamic from 'next/dynamic'; import { useRouter } from 'next/navigation'; import React from 'react'; import { useSafeLayoutEffect } from '../../client-boundary/hooks/useSafeLayoutEffect'; import { ClerkNextOptionsProvider, useClerkNextOptions } from '../../client-boundary/NextOptionsContext'; +import { errorThrower } from '../../server/errorThrower'; import type { NextClerkProviderProps } from '../../types'; import { canUseKeyless } from '../../utils/feature-flags'; import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv'; @@ -16,14 +16,6 @@ import { ClerkScripts } from './ClerkScripts'; import { useAwaitablePush } from './useAwaitablePush'; import { useAwaitableReplace } from './useAwaitableReplace'; -/** - * LazyCreateKeylessApplication should only be loaded if the conditions below are met. - * Note: Using lazy() with Suspense instead of dynamic is not possible as React will throw a hydration error when `ClerkProvider` wraps `...` - */ -const LazyCreateKeylessApplication = dynamic(() => - import('./keyless-creator-reader.js').then(m => m.KeylessCreatorOrReader), -); - const NextClientClerkProvider = (props: NextClerkProviderProps) => { const { __internal_invokeMiddlewareOnAuthStateChange = true, __internal_scriptsSlot, children } = props; const router = useRouter(); @@ -115,9 +107,5 @@ export const ClientClerkProvider = ( return {children}; } - return ( - - {children} - - ); + return errorThrower.throwMissingPublishableKeyError(); }; diff --git a/packages/nextjs/src/app-router/client/keyless-cookie-sync.tsx b/packages/nextjs/src/app-router/client/keyless-cookie-sync.tsx deleted file mode 100644 index bf250d1cd39..00000000000 --- a/packages/nextjs/src/app-router/client/keyless-cookie-sync.tsx +++ /dev/null @@ -1,27 +0,0 @@ -'use client'; - -import type { AccountlessApplication } from '@clerk/backend'; -import { useSelectedLayoutSegments } from 'next/navigation'; -import type { PropsWithChildren } from 'react'; -import { useEffect } from 'react'; - -import { canUseKeyless } from '../../utils/feature-flags'; - -export function KeylessCookieSync(props: PropsWithChildren) { - const segments = useSelectedLayoutSegments(); - const isNotFoundRoute = segments[0]?.startsWith('/_not-found') || false; - - useEffect(() => { - if (canUseKeyless && !isNotFoundRoute) { - void import('../keyless-actions.js').then(m => - m.syncKeylessConfigAction({ - ...props, - // Preserve the current url and return back, once keys are synced in the middleware - returnUrl: window.location.href, - }), - ); - } - }, [isNotFoundRoute]); - - return props.children; -} diff --git a/packages/nextjs/src/app-router/client/keyless-creator-reader.tsx b/packages/nextjs/src/app-router/client/keyless-creator-reader.tsx deleted file mode 100644 index 7d481b744fa..00000000000 --- a/packages/nextjs/src/app-router/client/keyless-creator-reader.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useSelectedLayoutSegments } from 'next/navigation'; -import React, { useEffect } from 'react'; - -import type { NextClerkProviderProps } from '../../types'; -import { createOrReadKeylessAction } from '../keyless-actions'; - -export const KeylessCreatorOrReader = (props: NextClerkProviderProps) => { - const { children } = props; - const segments = useSelectedLayoutSegments(); - const isNotFoundRoute = segments[0]?.startsWith('/_not-found') || false; - const [state, fetchKeys] = React.useActionState(createOrReadKeylessAction, null); - useEffect(() => { - if (isNotFoundRoute) { - return; - } - React.startTransition(() => { - fetchKeys(); - }); - }, [isNotFoundRoute]); - - if (!React.isValidElement(children)) { - return children; - } - - return React.cloneElement(children, { - key: state?.publishableKey, - publishableKey: state?.publishableKey, - __internal_keyless_claimKeylessApplicationUrl: state?.claimUrl, - __internal_keyless_copyInstanceKeysUrl: state?.apiKeysUrl, - __internal_bypassMissingPublishableKey: true, - } as any); -}; diff --git a/packages/nextjs/src/app-router/keyless-actions.ts b/packages/nextjs/src/app-router/keyless-actions.ts index 209f91226d8..02de03adb11 100644 --- a/packages/nextjs/src/app-router/keyless-actions.ts +++ b/packages/nextjs/src/app-router/keyless-actions.ts @@ -1,93 +1,8 @@ 'use server'; -import type { AccountlessApplication } from '@clerk/backend'; -import { cookies, headers } from 'next/headers'; -import { redirect, RedirectType } from 'next/navigation'; -import { errorThrower } from '../server/errorThrower'; -import { detectClerkMiddleware } from '../server/headers-utils'; -import { getKeylessCookieName, getKeylessCookieValue } from '../server/keyless'; -import { clerkDevelopmentCache, createKeylessModeMessage } from '../server/keyless-log-cache'; import { keyless } from '../server/keyless-node'; import { canUseKeyless } from '../utils/feature-flags'; -type SetCookieOptions = Parameters>['set']>[2]; - -const keylessCookieConfig = { - secure: false, - httpOnly: false, - sameSite: 'lax', -} satisfies SetCookieOptions; - -export async function syncKeylessConfigAction(args: AccountlessApplication & { returnUrl: string }): Promise { - const { claimUrl, publishableKey, secretKey, returnUrl } = args; - const cookieStore = await cookies(); - const request = new Request('https://placeholder.com', { headers: await headers() }); - - const keylessCookie = await getKeylessCookieValue(name => cookieStore.get(name)?.value); - const pksMatch = keylessCookie?.publishableKey === publishableKey; - const sksMatch = keylessCookie?.secretKey === secretKey; - if (pksMatch && sksMatch) { - // Return early, syncing in not needed. - return; - } - - // Set the new keys in the cookie. - cookieStore.set( - await getKeylessCookieName(), - JSON.stringify({ claimUrl, publishableKey, secretKey }), - keylessCookieConfig, - ); - - // Request works at runtime since detectClerkMiddleware checks for Request via isRequestWebAPI - if (detectClerkMiddleware(request as Parameters[0])) { - /** - * Force middleware to execute to read the new keys from the cookies and populate the authentication state correctly. - */ - redirect(`/clerk-sync-keyless?returnUrl=${returnUrl}`, RedirectType.replace); - } - - return; -} - -export async function createOrReadKeylessAction(): Promise> { - if (!canUseKeyless) { - return null; - } - - let result; - try { - result = await keyless().getOrCreateKeys(); - } catch { - result = null; - } - - if (!result) { - errorThrower.throwMissingPublishableKeyError(); - return null; - } - - /** - * Notify developers. - */ - clerkDevelopmentCache?.log({ - cacheKey: result.publishableKey, - msg: createKeylessModeMessage(result), - }); - - const { claimUrl, publishableKey, secretKey, apiKeysUrl } = result; - void (await cookies()).set( - await getKeylessCookieName(), - JSON.stringify({ claimUrl, publishableKey, secretKey }), - keylessCookieConfig, - ); - - return { - claimUrl, - publishableKey, - apiKeysUrl, - }; -} - export async function deleteKeylessAction() { if (!canUseKeyless) { return; diff --git a/packages/nextjs/src/app-router/server/ClerkProvider.tsx b/packages/nextjs/src/app-router/server/ClerkProvider.tsx index 9e454b83b4e..b43d83306c8 100644 --- a/packages/nextjs/src/app-router/server/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/server/ClerkProvider.tsx @@ -3,6 +3,7 @@ import type { InitialState, Without } from '@clerk/shared/types'; import React, { Suspense } from 'react'; import { getDynamicAuthData } from '../../server/buildClerkProps'; +import { errorThrower } from '../../server/errorThrower'; import type { NextClerkProviderProps } from '../../types'; import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv'; import { ClientClerkProvider } from '../client/ClerkProvider'; @@ -52,6 +53,9 @@ export async function ClerkProvider( ) : undefined; if (shouldRunAsKeyless) { + if (!propsWithEnvs.publishableKey) { + errorThrower.throwMissingPublishableKeyError(); + } return ( { const { rest, runningWithClaimedKeys, __internal_scriptsSlot, children } = props; - // NOTE: Create or read keys on every render. Usually this means only on hard refresh or hard navigations. + // Read-only: the SDK no longer mints keyless applications, it only reads claimed keys from disk. const newOrReadKeys = await import('../../server/keyless-node.js') - .then(mod => mod.keyless().getOrCreateKeys()) + .then(mod => mod.keyless().readKeys() ?? null) .catch(() => null); - const { clerkDevelopmentCache, createConfirmationMessage, createKeylessModeMessage } = - await import('../../server/keyless-log-cache.js'); + const { clerkDevelopmentCache, createConfirmationMessage } = await import('../../server/keyless-log-cache.js'); if (!newOrReadKeys) { // When case keyless should run, but keys are not available, then fallback to throwing for missing keys @@ -98,31 +95,7 @@ export const KeylessProvider = async (props: KeylessProviderProps) => { cacheKey: `${newOrReadKeys.publishableKey}_claimed`, msg: createConfirmationMessage(), }); - - return clientProvider; - } - - const KeylessCookieSync = await import('../client/keyless-cookie-sync.js').then(mod => mod.KeylessCookieSync); - - const headerStore = await headers(); - /** - * Allow developer to return to local application after claiming - */ - const host = headerStore.get('x-forwarded-host'); - const proto = headerStore.get('x-forwarded-proto'); - - const claimUrl = new URL(newOrReadKeys.claimUrl); - if (host && proto) { - onlyTry(() => claimUrl.searchParams.set('return_url', new URL(`${proto}://${host}`).href)); } - /** - * Notify developers. - */ - clerkDevelopmentCache?.log({ - cacheKey: newOrReadKeys.publishableKey, - msg: createKeylessModeMessage({ ...newOrReadKeys, claimUrl: claimUrl.href }), - }); - - return {clientProvider}; + return clientProvider; }; diff --git a/packages/nextjs/src/server/__tests__/clerkMiddlewareKeyless.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddlewareKeyless.test.ts new file mode 100644 index 00000000000..8f4e8842cf4 --- /dev/null +++ b/packages/nextjs/src/server/__tests__/clerkMiddlewareKeyless.test.ts @@ -0,0 +1,54 @@ +import { automatedEnvironmentVariables } from '@clerk/shared/utils'; +import type { NextFetchEvent } from 'next/server'; +import { NextRequest } from 'next/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// The mock SHOULD exist before the imports: unlike clerkMiddleware.test.ts, keys are empty so the +// missing-key error path is reachable. +vi.mock(import('../constants.js'), async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + PUBLISHABLE_KEY: '', + SECRET_KEY: '', + }; +}); + +describe('clerkMiddleware when Clerk env vars are missing', () => { + beforeEach(() => { + vi.stubEnv('NODE_ENV', 'development'); + vi.stubEnv('NEXT_PUBLIC_CLERK_KEYLESS_DISABLED', undefined); + automatedEnvironmentVariables.forEach(name => { + vi.stubEnv(name, undefined); + vi.stubGlobal(name, undefined); + }); + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + const runMiddleware = async (headers?: Record) => { + const { clerkMiddleware } = await import('../clerkMiddleware.js'); + const request = new NextRequest('https://example.com/protected', { headers }); + return clerkMiddleware()(request, {} as NextFetchEvent); + }; + + it('throws the missing key error pointing at the CLI instead of bootstrapping keyless', async () => { + await expect(runMiddleware()).rejects.toThrow(/Missing publishableKey/); + await expect(runMiddleware()).rejects.toThrow(/npx clerk@latest init/); + }); + + it('throws the same error for machine-token requests', async () => { + await expect(runMiddleware({ authorization: 'Bearer mt_xxxxxxxx' })).rejects.toThrow(/npx clerk@latest init/); + }); + + it('throws the same error regardless of NODE_ENV', async () => { + vi.stubEnv('NODE_ENV', 'production'); + await expect(runMiddleware()).rejects.toThrow(/npx clerk@latest init/); + await expect(runMiddleware()).rejects.toThrow(/npx clerk@latest deploy/); + }); +}); diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index 9e6699de60d..98d7edb26f4 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -1,4 +1,4 @@ -import type { AccountlessApplication, AuthObject, ClerkClient } from '@clerk/backend'; +import type { AuthObject, ClerkClient } from '@clerk/backend'; import type { AuthenticatedState, AuthenticateRequestOptions, @@ -12,11 +12,9 @@ import type { import { AuthStatus, constants, - createBootstrapSignedOutState, createClerkRequest, createRedirect, getAuthObjectForAcceptedToken, - isMachineTokenByPrefix, isTokenTypeAccepted, makeAuthObjectSerializable, TokenType, @@ -35,13 +33,10 @@ import type { GetAuthOptions } from '../server/createGetAuth'; import { isRedirect, serverRedirectWithAuth, setHeader } from '../utils'; import type { Logger, LoggerNoCommit } from '../utils/debugLogger'; import { withLogger } from '../utils/debugLogger'; -import { canUseKeyless } from '../utils/feature-flags'; import { clerkClient } from './clerkClient'; import { DOMAIN, PROXY_URL, PUBLISHABLE_KEY, SECRET_KEY, SIGN_IN_URL, SIGN_UP_URL } from './constants'; import { type ContentSecurityPolicyOptions, createContentSecurityPolicyHeaders } from './content-security-policy'; import { errorThrower } from './errorThrower'; -import { getHeader } from './headers-utils'; -import { getKeylessCookieValue } from './keyless'; import { clerkMiddlewareRequestDataStorage, clerkMiddlewareRequestDataStore } from './middleware-storage'; import { isNextjsNotFoundError, @@ -151,14 +146,11 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl // Handles the case where `options` is a callback function to dynamically access `NextRequest` const resolvedParams = typeof params === 'function' ? await params(request) : params; - const keyless = await getKeylessCookieValue(name => request.cookies.get(name)?.value); - - const publishableKey = assertKey( - resolvedParams.publishableKey || PUBLISHABLE_KEY || keyless?.publishableKey, - () => errorThrower.throwMissingPublishableKeyError(), + const publishableKey = assertKey(resolvedParams.publishableKey || PUBLISHABLE_KEY, () => + errorThrower.throwMissingPublishableKeyError(), ); - const secretKey = assertKey(resolvedParams.secretKey || SECRET_KEY || keyless?.secretKey, () => + const secretKey = assertKey(resolvedParams.secretKey || SECRET_KEY, () => errorThrower.throwMissingSecretKeyError(), ); @@ -240,93 +232,19 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl handler, options, resolvedParams, - keyless, - logger, - }); - }); - - /** - * Runs the user's handler against a synthetic signed-out `RequestState` during the keyless - * bootstrap window, so authorization fails closed until a publishable key is provisioned. - */ - const bootstrapNextMiddleware: NextMiddleware = withLogger('clerkMiddleware', logger => async (request, event) => { - const resolvedParams = typeof params === 'function' ? await params(request) : params; - const keyless = await getKeylessCookieValue(name => request.cookies.get(name)?.value); - - const signInUrl = resolvedParams.signInUrl || SIGN_IN_URL || ''; - const signUpUrl = resolvedParams.signUpUrl || SIGN_UP_URL || ''; - - const options = { - publishableKey: '', - secretKey: '', - signInUrl, - signUpUrl, - ...resolvedParams, - }; - - clerkMiddlewareRequestDataStore.set('requestData', options); - - if (options.debug) { - logger.enable(); - } - - const clerkRequest = createClerkRequest(request); - logger.debug('keyless bootstrap (no publishable key)', () => ({ signInUrl, signUpUrl })); - logger.debug('url', () => clerkRequest.toJSON()); - - const requestState = createBootstrapSignedOutState({ signInUrl, signUpUrl }); - - return runHandlerWithRequestState({ - clerkRequest, - request, - event, - requestState, - handler, - options, - resolvedParams, - keyless, logger, }); }); - const keylessMiddleware: NextMiddleware = async (request, event) => { - /** - * This mechanism replaces a full-page reload. Ensures that middleware will re-run and authenticate the request properly without the secret key or publishable key to be missing. - */ - if (isKeylessSyncRequest(request)) { - return returnBackFromKeylessSync(request); - } - - const resolvedParams = typeof params === 'function' ? await params(request) : params; - const keyless = await getKeylessCookieValue(name => request.cookies.get(name)?.value); - - const isMissingPublishableKey = !(resolvedParams.publishableKey || PUBLISHABLE_KEY || keyless?.publishableKey); - const authHeader = getHeader(request, constants.Headers.Authorization)?.replace('Bearer ', '') ?? ''; - - if (isMissingPublishableKey && !isMachineTokenByPrefix(authHeader)) { - return bootstrapNextMiddleware(request, event); - } - - return baseNextMiddleware(request, event); - }; - - const nextMiddleware: NextMiddleware = async (request, event) => { - if (canUseKeyless) { - return keylessMiddleware(request, event); - } - - return baseNextMiddleware(request, event); - }; - // 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); + return baseNextMiddleware(request, event); } // Otherwise, return a middleware that can be called with a request and event // eg, export default clerkMiddleware(auth => { ... }); - return nextMiddleware; + return baseNextMiddleware; }); return middleware; @@ -359,15 +277,12 @@ type RunHandlerWithRequestStateArgs = { signUpUrl: string; }; resolvedParams: ClerkMiddlewareOptions; - keyless: AccountlessApplication | undefined; logger: LoggerNoCommit; }; /** * Drives the post-authentication pipeline: handler invocation, CSP, redirects, header propagation, - * and response decoration. Accepts a pre-computed `requestState` so callers can supply either a - * real authentication result from `authenticateRequest()` or a synthetic signed-out state - * (e.g. during keyless bootstrap when no publishable key is available yet). + * and response decoration. */ async function runHandlerWithRequestState({ clerkRequest, @@ -377,10 +292,9 @@ async function runHandlerWithRequestState({ handler, options, resolvedParams, - keyless, logger, }: RunHandlerWithRequestStateArgs): Promise { - const { publishableKey, secretKey } = options; + const { publishableKey } = options; logger.debug('requestState', () => ({ status: requestState.status, @@ -470,38 +384,18 @@ async function runHandlerWithRequestState({ setRequestHeadersOnNextResponse(handlerResult, clerkRequest, { [constants.Headers.EnableDebug]: 'true' }); } - const keylessKeysForRequestData = - // Only pass keyless credentials when there are no explicit keys - secretKey === keyless?.secretKey - ? { - publishableKey: keyless?.publishableKey, - secretKey: keyless?.secretKey, - } - : {}; - decorateRequest( clerkRequest, handlerResult, requestState, resolvedParams, - keylessKeysForRequestData, + {}, authObject.tokenType === 'session_token' ? null : makeAuthObjectSerializable(authObject), ); return handlerResult; } -const isKeylessSyncRequest = (request: NextMiddlewareRequestParam) => - request.nextUrl.pathname === '/clerk-sync-keyless'; - -const returnBackFromKeylessSync = (request: NextMiddlewareRequestParam) => { - const returnUrl = request.nextUrl.searchParams.get('returnUrl'); - const url = new URL(request.url); - url.pathname = ''; - - return NextResponse.redirect(returnUrl || url.toString()); -}; - type AuthenticateRequest = Pick['authenticateRequest']; export const createAuthenticateRequestOptions = ( From c6ee23d3b7a17408f0a37f80eaeedb2ebf018ffb Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Wed, 19 Aug 2026 09:04:46 -0700 Subject: [PATCH 2/3] fix(e2e): accept non-2xx readiness for keyless tests without keys Co-Authored-By: Claude Fable 5 --- integration/models/application.ts | 17 +++++++++++++++-- integration/scripts/waitForServer.ts | 5 +++-- .../tests/next-middleware-keyless.test.ts | 3 ++- .../tests/next-quickstart-keyless.test.ts | 3 ++- 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/integration/models/application.ts b/integration/models/application.ts index 2afb03d134b..c74dab234f9 100644 --- a/integration/models/application.ts +++ b/integration/models/application.ts @@ -105,7 +105,15 @@ export const application = ( await run('pnpm list @clerk/* --depth 100', { cwd: appDirPath, log: clerkPackagesLog }); } }, - dev: async (opts: { port?: number; manualStart?: boolean; detached?: boolean; serverUrl?: string } = {}) => { + dev: async ( + opts: { + port?: number; + manualStart?: boolean; + detached?: boolean; + serverUrl?: string; + acceptAnyResponse?: boolean; + } = {}, + ) => { const log = logger.child({ prefix: 'dev' }).info; const port = opts.port || (await getPort()); const runtimeServerUrl = resolveServerUrl(opts.serverUrl, serverUrl, port); @@ -129,7 +137,12 @@ export const application = ( }); const shouldExit = () => !!proc.exitCode && proc.exitCode !== 0; - await waitForServer(runtimeServerUrl, { log, maxAttempts: Infinity, shouldExit }); + await waitForServer(runtimeServerUrl, { + log, + maxAttempts: Infinity, + shouldExit, + acceptAnyResponse: opts.acceptAnyResponse, + }); log(`Server started at ${runtimeServerUrl}, pid: ${proc.pid}`); cleanupFns.push(() => awaitableTreekill(proc.pid, 'SIGKILL')); state.serverUrl = runtimeServerUrl; diff --git a/integration/scripts/waitForServer.ts b/integration/scripts/waitForServer.ts index 6a5200ee7e9..817f9454541 100644 --- a/integration/scripts/waitForServer.ts +++ b/integration/scripts/waitForServer.ts @@ -3,11 +3,12 @@ type WaitForServerArgsType = { delayInMs?: number; maxAttempts?: number; shouldExit?: () => boolean; + acceptAnyResponse?: boolean; }; // Poll a url until it returns a 200 status code export const waitForServer = async (url: string, opts: WaitForServerArgsType) => { - const { log, delayInMs = 1000, maxAttempts = 20, shouldExit = () => false } = opts; + const { log, delayInMs = 1000, maxAttempts = 20, shouldExit = () => false, acceptAnyResponse = false } = opts; let attempts = 0; while (attempts < maxAttempts) { if (shouldExit()) { @@ -17,7 +18,7 @@ export const waitForServer = async (url: string, opts: WaitForServerArgsType) => try { log(`Polling ${url}...`); const res = await fetch(url); - if (res.ok) { + if (res.ok || acceptAnyResponse) { return Promise.resolve(); } } catch { diff --git a/integration/tests/next-middleware-keyless.test.ts b/integration/tests/next-middleware-keyless.test.ts index 76f33cee9bc..4b726ec0116 100644 --- a/integration/tests/next-middleware-keyless.test.ts +++ b/integration/tests/next-middleware-keyless.test.ts @@ -20,7 +20,8 @@ test.describe('Keyless mode | middleware authorization @nextjs', () => { app = await commonSetup.commit(); await app.setup(); await app.withEnv(appConfigs.envs.withKeyless); - await app.dev(); + // Without keys the app 500s on every request, so readiness can't wait for a 2xx + await app.dev({ acceptAnyResponse: true }); }); test.afterAll(async () => { diff --git a/integration/tests/next-quickstart-keyless.test.ts b/integration/tests/next-quickstart-keyless.test.ts index 1c92058845e..5457fb67505 100644 --- a/integration/tests/next-quickstart-keyless.test.ts +++ b/integration/tests/next-quickstart-keyless.test.ts @@ -25,7 +25,8 @@ test.describe('Keyless mode @quickstart', () => { app = await commonSetup.commit(); await app.setup(); await app.withEnv(appConfigs.envs.withKeyless); - await app.dev(); + // Without keys the app 500s on every request, so readiness can't wait for a 2xx + await app.dev({ acceptAnyResponse: true }); }); test.afterAll(async () => { From 5e965b23197df2f4b3966571b0123c3a76309faf Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Wed, 19 Aug 2026 09:32:56 -0700 Subject: [PATCH 3/3] docs(backend): align deprecation phrasing with repo convention Co-Authored-By: Claude Fable 5 --- packages/backend/src/tokens/authStatus.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/tokens/authStatus.ts b/packages/backend/src/tokens/authStatus.ts index 358b29e0b69..5282aa4b3a8 100644 --- a/packages/backend/src/tokens/authStatus.ts +++ b/packages/backend/src/tokens/authStatus.ts @@ -292,7 +292,7 @@ type BootstrapSignedOutParams = { * satellite redirects produced by `createRedirect` include the `__clerk_status=needs-sync` * marker required for the return-trip handshake. * - * @deprecated No longer used by `@clerk/nextjs`; kept for older published SDK versions. Remove in the next major. + * @deprecated No longer used by `@clerk/nextjs`; kept for older published SDK versions. Will be removed in the next major version. */ export function createBootstrapSignedOutState({ signInUrl = '',