From edaa83451f53b581b2ad2dd0e37528c45c1cd0b6 Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Thu, 3 Sep 2026 16:53:32 -0700 Subject: [PATCH 1/4] feat(nextjs): Print clerk init notice on dev keys When a Next.js app renders `` on the server with a development publishable key, print a one-time terminal line naming `npx clerk@latest init` and stating that no Clerk account is required. Coding agents that fabricate a well-formed `pk_test_` key build successfully today and never learn the command exists, because the only mention of it lives in the error thrown for a missing or malformed key. - Print from the Next.js provider render path, not from `parsePublishableKey`, so the line reaches `next build` and `next dev` terminals without touching browser consoles. - Print only during `next build` or `next dev`; deployed runtimes, Edge cold starts, and keyless mode stay silent. - Honor `unsafe_disableDevelopmentModeConsoleWarning` as a prop or env var, fixing the props merge that previously discarded the prop. - Export `accountlessInitGuidance` from `@clerk/shared/keys` so the notice and the existing fatal key errors share one sentence. - Cover both provider wirings with render tests, and extend the client-component build integration test to assert the line appears at build time and not when the built app is served. --- .changeset/nextjs-dev-key-init-notice.md | 10 ++ integration/tests/next-build.test.ts | 43 ++++++ .../src/app-router/client/ClerkProvider.tsx | 7 + .../client/__tests__/ClerkProvider.test.tsx | 79 ++++++++++ packages/nextjs/src/pages/ClerkProvider.tsx | 5 + .../pages/__tests__/ClerkProvider.test.tsx | 57 +++++++ .../src/utils/__tests__/devKeyNotice.test.ts | 146 ++++++++++++++++++ .../mergeNextClerkPropsWithEnv.test.ts | 28 ++++ packages/nextjs/src/utils/devKeyNotice.ts | 75 +++++++++ .../src/utils/mergeNextClerkPropsWithEnv.ts | 6 +- packages/shared/src/__tests__/keys.spec.ts | 14 ++ packages/shared/src/keys.ts | 9 +- 12 files changed, 475 insertions(+), 4 deletions(-) create mode 100644 .changeset/nextjs-dev-key-init-notice.md create mode 100644 packages/nextjs/src/app-router/client/__tests__/ClerkProvider.test.tsx create mode 100644 packages/nextjs/src/pages/__tests__/ClerkProvider.test.tsx create mode 100644 packages/nextjs/src/utils/__tests__/devKeyNotice.test.ts create mode 100644 packages/nextjs/src/utils/devKeyNotice.ts diff --git a/.changeset/nextjs-dev-key-init-notice.md b/.changeset/nextjs-dev-key-init-notice.md new file mode 100644 index 00000000000..2098e3d4adb --- /dev/null +++ b/.changeset/nextjs-dev-key-init-notice.md @@ -0,0 +1,10 @@ +--- +'@clerk/nextjs': minor +'@clerk/shared': minor +--- + +Print a one-time notice in the server terminal when `` renders with a development publishable key, naming `npx clerk@latest init` as the way to get working keys without a Clerk account. The notice appears once per process, so once per build worker during `next build`, and on the first server render under `next dev`. It never prints in the browser, in deployed runtimes, or when the keys came from keyless mode. It is silenced by the existing `unsafe_disableDevelopmentModeConsoleWarning` prop or `NEXT_PUBLIC_CLERK_UNSAFE_DISABLE_DEVELOPMENT_MODE_CONSOLE_WARNING` env var. + +Fix `unsafe_disableDevelopmentModeConsoleWarning` being ignored when passed as a prop to the Next.js ``; previously only the env var took effect, so the prop did not silence the browser development-keys warning either. + +`@clerk/shared/keys` now exports `accountlessInitGuidance`, the sentence used by this notice and by the existing missing-key errors. diff --git a/integration/tests/next-build.test.ts b/integration/tests/next-build.test.ts index 697384b9922..7822c293be6 100644 --- a/integration/tests/next-build.test.ts +++ b/integration/tests/next-build.test.ts @@ -131,11 +131,33 @@ export default function RootLayout({ children }: { children: React.ReactNode }) ); } `, + ) + .addFile( + 'src/app/dev-key-notice/node/page.tsx', + () => `export const dynamic = 'force-dynamic'; + +export default function Page() { + console.log('dev-key-notice-sentinel:node'); + return

dev-key-notice-marker:node

; +} +`, + ) + .addFile( + 'src/app/dev-key-notice/edge/page.tsx', + () => `export const runtime = 'edge'; +export const dynamic = 'force-dynamic'; + +export default function Page() { + console.log('dev-key-notice-sentinel:edge'); + return

dev-key-notice-marker:edge

; +} +`, ) .commit(); await app.setup(); await app.withEnv(appConfigs.envs.withEmailCodes); await app.build(); + await app.serve(); }); test.afterAll(async () => { @@ -155,6 +177,27 @@ export default function RootLayout({ children }: { children: React.ReactNode }) expect(notFoundPageLine).toContain(staticIndicator); }); + test('Prints the clerk init hint for development keys when is a client component', () => { + expect(app.buildOutput).toContain('Development keys in use'); + }); + + test('Does not print the clerk init hint when the built app is served', async () => { + // Both pages render the provider at request time, one on Node and one on Edge, and log a sentinel so + // the negative assertion below only runs once their server output has been captured. + for (const target of ['node', 'edge']) { + const res = await fetch(`${app.serverUrl}/dev-key-notice/${target}`); + expect(res.status).toBe(200); + expect(await res.text()).toContain(`dev-key-notice-marker:${target}`); + } + await expect + .poll(() => app.serveOutput, { timeout: 15_000 }) + .toMatch( + /dev-key-notice-sentinel:node[\s\S]*dev-key-notice-sentinel:edge|dev-key-notice-sentinel:edge[\s\S]*dev-key-notice-sentinel:node/, + ); + + expect(app.serveOutput).not.toContain('Development keys in use'); + }); + /** * Sometimes utilities from `/server` may use Node APIs even if `clerkMiddleware` does not consumes them. * This happens because of code for node runtime and edge runtime is bundled together in the `/server/index.ts` barrel file. diff --git a/packages/nextjs/src/app-router/client/ClerkProvider.tsx b/packages/nextjs/src/app-router/client/ClerkProvider.tsx index fb6834585a4..ebac0d70189 100644 --- a/packages/nextjs/src/app-router/client/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/client/ClerkProvider.tsx @@ -8,6 +8,7 @@ import { useSafeLayoutEffect } from '../../client-boundary/hooks/useSafeLayoutEf import { ClerkNextOptionsProvider, useClerkNextOptions } from '../../client-boundary/NextOptionsContext'; import { errorThrower } from '../../server/errorThrower'; import type { NextClerkProviderProps } from '../../types'; +import { maybeShowDevelopmentKeyNotice } from '../../utils/devKeyNotice'; import { canUseKeyless } from '../../utils/feature-flags'; import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv'; import { RouterTelemetry } from '../../utils/router-telemetry'; @@ -76,6 +77,12 @@ const NextClientClerkProvider = (props: NextClerkProviderPr routerReplace: replace, }); + maybeShowDevelopmentKeyNotice({ + publishableKey: mergedProps.publishableKey, + disabled: mergedProps.unsafe_disableDevelopmentModeConsoleWarning, + keyless: Boolean(mergedProps.__internal_keyless_claimKeylessApplicationUrl), + }); + return ( diff --git a/packages/nextjs/src/app-router/client/__tests__/ClerkProvider.test.tsx b/packages/nextjs/src/app-router/client/__tests__/ClerkProvider.test.tsx new file mode 100644 index 00000000000..047dfffa5fe --- /dev/null +++ b/packages/nextjs/src/app-router/client/__tests__/ClerkProvider.test.tsx @@ -0,0 +1,79 @@ +/** + * @vitest-environment node + */ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { maybeShowDevelopmentKeyNotice } from '../../../utils/devKeyNotice'; +import { ClientClerkProvider } from '../ClerkProvider'; + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ refresh: vi.fn(), push: vi.fn(), replace: vi.fn() }), +})); +vi.mock('../useAwaitablePush', () => ({ useAwaitablePush: () => vi.fn() })); +vi.mock('../useAwaitableReplace', () => ({ useAwaitableReplace: () => vi.fn() })); +vi.mock('../../server-actions', () => ({ invalidateCacheAction: vi.fn() })); +vi.mock('../ClerkScripts', () => ({ ClerkScripts: () => null })); +vi.mock('../../../utils/router-telemetry', () => ({ RouterTelemetry: () => null })); +vi.mock('@clerk/react/internal', () => ({ + InternalClerkProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})); +vi.mock('../../../utils/devKeyNotice', () => ({ maybeShowDevelopmentKeyNotice: vi.fn() })); + +const notice = maybeShowDevelopmentKeyNotice as unknown as ReturnType; +const DEV_KEY = 'pk_test_ZmFrZS1jbGVyay5hY2NvdW50cy5kZXYk'; +const ORIGINAL_ENV = { ...process.env }; + +describe('ClientClerkProvider (server render)', () => { + beforeEach(() => { + delete process.env.NEXT_PUBLIC_CLERK_UNSAFE_DISABLE_DEVELOPMENT_MODE_CONSOLE_WARNING; + notice.mockClear(); + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it('asks for the development key notice with the resolved key', () => { + const html = renderToStaticMarkup(child); + + expect(html).toContain('child'); + expect(notice).toHaveBeenCalledTimes(1); + expect(notice).toHaveBeenCalledWith({ publishableKey: DEV_KEY, disabled: false, keyless: false }); + }); + + it('passes the opt-out through when set as a prop', () => { + renderToStaticMarkup( + + child + , + ); + + expect(notice).toHaveBeenCalledWith(expect.objectContaining({ disabled: true })); + }); + + it('passes the opt-out through when set by env var', () => { + process.env.NEXT_PUBLIC_CLERK_UNSAFE_DISABLE_DEVELOPMENT_MODE_CONSOLE_WARNING = 'true'; + + renderToStaticMarkup(child); + + expect(notice).toHaveBeenCalledWith(expect.objectContaining({ disabled: true })); + }); + + it('flags keys that came from keyless mode', () => { + renderToStaticMarkup( + + child + , + ); + + expect(notice).toHaveBeenCalledWith(expect.objectContaining({ keyless: true })); + }); +}); diff --git a/packages/nextjs/src/pages/ClerkProvider.tsx b/packages/nextjs/src/pages/ClerkProvider.tsx index 6d47886a470..85746d58330 100644 --- a/packages/nextjs/src/pages/ClerkProvider.tsx +++ b/packages/nextjs/src/pages/ClerkProvider.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { useSafeLayoutEffect } from '../client-boundary/hooks/useSafeLayoutEffect'; import { ClerkNextOptionsProvider } from '../client-boundary/NextOptionsContext'; import type { NextClerkProviderProps } from '../types'; +import { maybeShowDevelopmentKeyNotice } from '../utils/devKeyNotice'; import { invalidateNextRouterCache } from '../utils/invalidateNextRouterCache'; import { mergeNextClerkPropsWithEnv } from '../utils/mergeNextClerkPropsWithEnv'; import { removeBasePath } from '../utils/removeBasePath'; @@ -46,6 +47,10 @@ export function ClerkProvider({ children, ...props }: NextC routerPush: navigate, routerReplace: replaceNavigate, }); + maybeShowDevelopmentKeyNotice({ + publishableKey: mergedProps.publishableKey, + disabled: mergedProps.unsafe_disableDevelopmentModeConsoleWarning, + }); // ClerkProvider automatically injects __clerk_ssr_state // getAuth returns a user-facing authServerSideProps that hides __clerk_ssr_state // @ts-expect-error initialState is hidden from the types as it's a private prop diff --git a/packages/nextjs/src/pages/__tests__/ClerkProvider.test.tsx b/packages/nextjs/src/pages/__tests__/ClerkProvider.test.tsx new file mode 100644 index 00000000000..85300518d2b --- /dev/null +++ b/packages/nextjs/src/pages/__tests__/ClerkProvider.test.tsx @@ -0,0 +1,57 @@ +/** + * @vitest-environment node + */ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { maybeShowDevelopmentKeyNotice } from '../../utils/devKeyNotice'; +import { ClerkProvider } from '../ClerkProvider'; + +vi.mock('next/router', () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); +vi.mock('../ClerkScripts', () => ({ ClerkScripts: () => null })); +vi.mock('../../utils/router-telemetry', () => ({ RouterTelemetry: () => null })); +vi.mock('@clerk/react/internal', () => ({ + InternalClerkProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + setClerkJSLoadingErrorPackageName: vi.fn(), + setErrorThrowerOptions: vi.fn(), +})); +vi.mock('../../utils/devKeyNotice', () => ({ maybeShowDevelopmentKeyNotice: vi.fn() })); + +const notice = maybeShowDevelopmentKeyNotice as unknown as ReturnType; +const DEV_KEY = 'pk_test_ZmFrZS1jbGVyay5hY2NvdW50cy5kZXYk'; +const ORIGINAL_ENV = { ...process.env }; + +describe('Pages Router ClerkProvider (server render)', () => { + beforeEach(() => { + delete process.env.NEXT_PUBLIC_CLERK_UNSAFE_DISABLE_DEVELOPMENT_MODE_CONSOLE_WARNING; + notice.mockClear(); + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it('asks for the development key notice with the resolved key and opt-out', () => { + const html = renderToStaticMarkup(child); + + expect(html).toContain('child'); + expect(notice).toHaveBeenCalledTimes(1); + expect(notice).toHaveBeenCalledWith({ publishableKey: DEV_KEY, disabled: false }); + }); + + it('passes the opt-out through when set as a prop', () => { + renderToStaticMarkup( + + child + , + ); + + expect(notice).toHaveBeenCalledWith(expect.objectContaining({ disabled: true })); + }); +}); diff --git a/packages/nextjs/src/utils/__tests__/devKeyNotice.test.ts b/packages/nextjs/src/utils/__tests__/devKeyNotice.test.ts new file mode 100644 index 00000000000..e8583f46a8c --- /dev/null +++ b/packages/nextjs/src/utils/__tests__/devKeyNotice.test.ts @@ -0,0 +1,146 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { __resetDevelopmentKeyNoticeForTests, maybeShowDevelopmentKeyNotice } from '../devKeyNotice'; + +// pk_test_ + base64('fake-clerk.accounts.dev$') +const DEV_KEY = 'pk_test_ZmFrZS1jbGVyay5hY2NvdW50cy5kZXYk'; +const LIVE_KEY = 'pk_live_Zm9vLmNsZXJrLmNvbSQ='; +// pk_test_ + base64('evil.dev\nforged line$') +const DEV_KEY_WITH_NEWLINE = `pk_test_${Buffer.from('evil.dev\nforged line$').toString('base64')}`; +const ORIGINAL_ENV = { ...process.env }; + +describe('maybeShowDevelopmentKeyNotice', () => { + let logSpy: ReturnType; + + beforeEach(() => { + __resetDevelopmentKeyNoticeForTests(); + // Default to the `next build` environment; individual tests override it. + process.env.NEXT_PHASE = 'phase-production-build'; + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + vi.unstubAllEnvs(); + process.env = { ...ORIGINAL_ENV }; + }); + + const printed = () => logSpy.mock.calls.map((call: unknown[]) => String(call[0])).join('\n'); + + it('prints once for a development key, naming clerk init and the instance', () => { + maybeShowDevelopmentKeyNotice({ publishableKey: DEV_KEY }); + maybeShowDevelopmentKeyNotice({ publishableKey: DEV_KEY }); + maybeShowDevelopmentKeyNotice({ publishableKey: DEV_KEY }); + + expect(logSpy).toHaveBeenCalledTimes(1); + expect(printed()).toContain('npx clerk@latest init'); + expect(printed()).toContain('No Clerk account or login required'); + expect(printed()).toContain('(fake-clerk.accounts.dev)'); + }); + + it('prints under next dev without a build phase', () => { + delete process.env.NEXT_PHASE; + vi.stubEnv('NODE_ENV', 'development'); + + maybeShowDevelopmentKeyNotice({ publishableKey: DEV_KEY }); + + expect(logSpy).toHaveBeenCalledTimes(1); + }); + + it('prints nothing in a deployed production runtime', () => { + delete process.env.NEXT_PHASE; + vi.stubEnv('NODE_ENV', 'production'); + + maybeShowDevelopmentKeyNotice({ publishableKey: DEV_KEY }); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('prints nothing in a deployed Edge Runtime', () => { + delete process.env.NEXT_PHASE; + vi.stubEnv('NODE_ENV', 'production'); + (globalThis as { EdgeRuntime?: string }).EdgeRuntime = 'edge-runtime'; + + try { + maybeShowDevelopmentKeyNotice({ publishableKey: DEV_KEY }); + expect(logSpy).not.toHaveBeenCalled(); + } finally { + delete (globalThis as { EdgeRuntime?: string }).EdgeRuntime; + } + }); + + it('prints nothing for a production key', () => { + maybeShowDevelopmentKeyNotice({ publishableKey: LIVE_KEY }); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('prints nothing for a missing or malformed key', () => { + maybeShowDevelopmentKeyNotice({ publishableKey: undefined }); + maybeShowDevelopmentKeyNotice({ publishableKey: '' }); + maybeShowDevelopmentKeyNotice({ publishableKey: 'pk_test_not-base64!' }); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('prints nothing when disabled', () => { + maybeShowDevelopmentKeyNotice({ publishableKey: DEV_KEY, disabled: true }); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('prints nothing when the keys came from keyless mode', () => { + maybeShowDevelopmentKeyNotice({ publishableKey: DEV_KEY, keyless: true }); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('omits the instance when the decoded key is not safe to print', () => { + maybeShowDevelopmentKeyNotice({ publishableKey: DEV_KEY_WITH_NEWLINE }); + + expect(logSpy).toHaveBeenCalledTimes(1); + expect(printed()).not.toContain('forged'); + expect(printed()).toContain('Development keys in use.'); + expect(printed()).toContain('npx clerk@latest init'); + }); + + it('prints nothing in a browser-like environment', () => { + (globalThis as { window?: unknown }).window = {}; + + try { + maybeShowDevelopmentKeyNotice({ publishableKey: DEV_KEY }); + expect(logSpy).not.toHaveBeenCalled(); + } finally { + delete (globalThis as { window?: unknown }).window; + } + }); + + it('prints in Next.js Edge Runtime under next dev', () => { + delete process.env.NEXT_PHASE; + vi.stubEnv('NODE_ENV', 'development'); + (globalThis as { EdgeRuntime?: string }).EdgeRuntime = 'edge-runtime'; + + try { + maybeShowDevelopmentKeyNotice({ publishableKey: DEV_KEY }); + expect(logSpy).toHaveBeenCalledTimes(1); + } finally { + delete (globalThis as { EdgeRuntime?: string }).EdgeRuntime; + } + }); + + it('does not throw if console.log fails, and retries on the next call', () => { + logSpy.mockImplementationOnce(() => { + throw new Error('console broken'); + }); + + expect(() => maybeShowDevelopmentKeyNotice({ publishableKey: DEV_KEY })).not.toThrow(); + + maybeShowDevelopmentKeyNotice({ publishableKey: DEV_KEY }); + + expect(logSpy).toHaveBeenCalledTimes(2); + expect(printed()).toContain('npx clerk@latest init'); + }); +}); diff --git a/packages/nextjs/src/utils/__tests__/mergeNextClerkPropsWithEnv.test.ts b/packages/nextjs/src/utils/__tests__/mergeNextClerkPropsWithEnv.test.ts index 18bab0aa982..fa8965441ce 100644 --- a/packages/nextjs/src/utils/__tests__/mergeNextClerkPropsWithEnv.test.ts +++ b/packages/nextjs/src/utils/__tests__/mergeNextClerkPropsWithEnv.test.ts @@ -9,6 +9,34 @@ describe('mergeNextClerkPropsWithEnv', () => { process.env = { ...ORIGINAL_ENV }; }); + describe('unsafe_disableDevelopmentModeConsoleWarning', () => { + it('is false when neither the prop nor the env var is set', () => { + expect(mergeNextClerkPropsWithEnv({}).unsafe_disableDevelopmentModeConsoleWarning).toBe(false); + }); + + it('is true when set as a prop', () => { + expect( + mergeNextClerkPropsWithEnv({ unsafe_disableDevelopmentModeConsoleWarning: true }) + .unsafe_disableDevelopmentModeConsoleWarning, + ).toBe(true); + }); + + it('is true when set by env var', () => { + process.env.NEXT_PUBLIC_CLERK_UNSAFE_DISABLE_DEVELOPMENT_MODE_CONSOLE_WARNING = 'true'; + + expect(mergeNextClerkPropsWithEnv({}).unsafe_disableDevelopmentModeConsoleWarning).toBe(true); + }); + + it('is true when the env var is set even if the prop is explicitly false', () => { + process.env.NEXT_PUBLIC_CLERK_UNSAFE_DISABLE_DEVELOPMENT_MODE_CONSOLE_WARNING = '1'; + + expect( + mergeNextClerkPropsWithEnv({ unsafe_disableDevelopmentModeConsoleWarning: false }) + .unsafe_disableDevelopmentModeConsoleWarning, + ).toBe(true); + }); + }); + it('auto-derives a relative proxyUrl for Vercel production static generation', () => { process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = 'pk_live_Zm9vLmNsZXJrLmNvbSQ='; process.env.VERCEL_TARGET_ENV = 'production'; diff --git a/packages/nextjs/src/utils/devKeyNotice.ts b/packages/nextjs/src/utils/devKeyNotice.ts new file mode 100644 index 00000000000..b0ed2a94de6 --- /dev/null +++ b/packages/nextjs/src/utils/devKeyNotice.ts @@ -0,0 +1,75 @@ +import { accountlessInitGuidance, parsePublishableKey } from '@clerk/shared/keys'; + +const PROCESS_FLAG = Symbol.for('@clerk/nextjs.developmentKeyNoticeShown'); + +function hasSeen(): boolean { + return Boolean((globalThis as Record)[PROCESS_FLAG]); +} + +function markSeen(): void { + (globalThis as Record)[PROCESS_FLAG] = true; +} + +// Keeps a forged key from injecting escape sequences or extra lines into the terminal. +function isTerminalSafeInstance(value: string): boolean { + return /^[a-z0-9.-]+$/i.test(value); +} + +// CI builds print on purpose (sandboxed agents only see build output); PHASE_PRODUCTION_BUILD is hardcoded to keep next/constants out of client bundles. +function isBuildOrDevServer(): boolean { + if (typeof process === 'undefined' || !process.env) { + return false; + } + return process.env.NEXT_PHASE === 'phase-production-build' || process.env.NODE_ENV === 'development'; +} + +export type DevelopmentKeyNoticeOptions = { + publishableKey?: string; + /** + * The resolved `unsafe_disableDevelopmentModeConsoleWarning` option (prop or env var). + */ + disabled?: boolean; + /** + * Keys came from keyless mode, which prints its own guidance. + */ + keyless?: boolean; +}; + +/** + * Print a one-time terminal notice, per process, when `` renders on the server with a + * development publishable key. The notice names `npx clerk@latest init` so that a developer, or a + * coding agent reading build output, learns that working keys need no Clerk account. Prints only + * during `next build` and under `next dev`; browsers and deployed runtimes are skipped. Never throws. + */ +export function maybeShowDevelopmentKeyNotice(options: DevelopmentKeyNoticeOptions): void { + try { + if (typeof window !== 'undefined' || options.disabled === true || options.keyless === true || hasSeen()) { + return; + } + if (!isBuildOrDevServer()) { + return; + } + const parsed = parsePublishableKey(options.publishableKey); + if (parsed?.instanceType !== 'development') { + return; + } + if (typeof console === 'undefined' || typeof console.log !== 'function') { + return; + } + const instance = isTerminalSafeInstance(parsed.frontendApi) ? ` (${parsed.frontendApi})` : ''; + // Unconditional for development keys: the SDK cannot tell a real instance from a fabricated key of the same shape without a network call, so no reachability check belongs here. + console.log(`\n\x1b[35m[Clerk]:\x1b[0m Development keys in use${instance}. ${accountlessInitGuidance}\n`); + markSeen(); + } catch { + // never let the notice break rendering + } +} + +/** + * Test-only: clear the in-process flag so the next call re-runs the gating logic. + * + * @internal + */ +export function __resetDevelopmentKeyNoticeForTests(): void { + delete (globalThis as Record)[PROCESS_FLAG]; +} diff --git a/packages/nextjs/src/utils/mergeNextClerkPropsWithEnv.ts b/packages/nextjs/src/utils/mergeNextClerkPropsWithEnv.ts index 491e6cf810d..80ef00943bd 100644 --- a/packages/nextjs/src/utils/mergeNextClerkPropsWithEnv.ts +++ b/packages/nextjs/src/utils/mergeNextClerkPropsWithEnv.ts @@ -61,8 +61,8 @@ export const mergeNextClerkPropsWithEnv = ( debug: isTruthy(process.env.NEXT_PUBLIC_CLERK_TELEMETRY_DEBUG), }, sdkMetadata: SDK_METADATA, - unsafe_disableDevelopmentModeConsoleWarning: isTruthy( - process.env.NEXT_PUBLIC_CLERK_UNSAFE_DISABLE_DEVELOPMENT_MODE_CONSOLE_WARNING, - ), + unsafe_disableDevelopmentModeConsoleWarning: + props.unsafe_disableDevelopmentModeConsoleWarning === true || + isTruthy(process.env.NEXT_PUBLIC_CLERK_UNSAFE_DISABLE_DEVELOPMENT_MODE_CONSOLE_WARNING), }; }; diff --git a/packages/shared/src/__tests__/keys.spec.ts b/packages/shared/src/__tests__/keys.spec.ts index c9ec4d42acc..47688afc4e7 100644 --- a/packages/shared/src/__tests__/keys.spec.ts +++ b/packages/shared/src/__tests__/keys.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it, test } from 'vitest'; import { + accountlessInitGuidance, buildPublishableKey, createDevOrStagingUrlCache, getCookieSuffix, @@ -85,6 +86,19 @@ describe('parsePublishableKey(key)', () => { ); }); + it('keeps the full guidance text on fatal errors and shares the init sentence with it', () => { + const expectedGuidance = + 'To create a Clerk application with valid keys, in your terminal run:\n\nnpx clerk@latest init\n\n`npx clerk@latest init` creates a Clerk application and writes keys to your .env file. No Clerk account or login required and the command is non-interactive.\n\nIf you have a Clerk application, run `npx clerk@latest env pull` to write the keys (`--instance prod` for production keys). Or copy them from https://dashboard.clerk.com/last-active?path=api-keys.'; + + expect(() => parsePublishableKey(undefined, { fatal: true })).toThrowError( + `Publishable key is missing. ${expectedGuidance}`, + ); + expect(() => parsePublishableKey('fake_pk', { fatal: true })).toThrowError( + `Publishable key not valid (expected format: pk_test_... or pk_live_...). ${expectedGuidance}`, + ); + expect(expectedGuidance).toContain(accountlessInitGuidance); + }); + it('applies the proxyUrl if provided', () => { expect( parsePublishableKey('pk_live_ZmFrZS1jbGVyay10ZXN0LmNsZXJrLmFjY291bnRzLmRldiQ=', { diff --git a/packages/shared/src/keys.ts b/packages/shared/src/keys.ts index c293a74bcb9..7e53eac2eba 100644 --- a/packages/shared/src/keys.ts +++ b/packages/shared/src/keys.ts @@ -98,11 +98,18 @@ function isValidDecodedPublishableKey(decoded: string): boolean { return withoutTrailing.includes('.'); } +/** + * The one sentence that explains how to get working Clerk keys without a Clerk account. + * Shared by every message that mentions `clerk init` so there is a single sentence to keep true. + */ +export const accountlessInitGuidance = + '`npx clerk@latest init` creates a Clerk application and writes keys to your .env file. No Clerk account or login required and the command is non-interactive.'; + const fatalKeyGuidance = `To create a Clerk application with valid keys, in your terminal run: npx clerk@latest init -\`npx clerk@latest init\` creates a Clerk application and writes keys to your .env file. No Clerk account or login required and the command is non-interactive. +${accountlessInitGuidance} If you have a Clerk application, run \`npx clerk@latest env pull\` to write the keys (\`--instance prod\` for production keys). Or copy them from https://dashboard.clerk.com/last-active?path=api-keys.`; From 68e64e8572f3f88fb9f5b6b46463a4ae8e98b5c9 Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Fri, 4 Sep 2026 16:34:57 -0700 Subject: [PATCH 2/4] fix(nextjs): Pass keyless state from Pages Router provider The App Router provider tells the development-key notice when keys came from keyless mode so it stays quiet; the Pages Router provider did not. Nothing sets that state on the Pages Router path today, so this keeps the two call sites identical rather than fixing observable behavior. --- packages/nextjs/src/pages/ClerkProvider.tsx | 1 + .../src/pages/__tests__/ClerkProvider.test.tsx | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/nextjs/src/pages/ClerkProvider.tsx b/packages/nextjs/src/pages/ClerkProvider.tsx index 85746d58330..2cecd97b4dc 100644 --- a/packages/nextjs/src/pages/ClerkProvider.tsx +++ b/packages/nextjs/src/pages/ClerkProvider.tsx @@ -50,6 +50,7 @@ export function ClerkProvider({ children, ...props }: NextC maybeShowDevelopmentKeyNotice({ publishableKey: mergedProps.publishableKey, disabled: mergedProps.unsafe_disableDevelopmentModeConsoleWarning, + keyless: Boolean(mergedProps.__internal_keyless_claimKeylessApplicationUrl), }); // ClerkProvider automatically injects __clerk_ssr_state // getAuth returns a user-facing authServerSideProps that hides __clerk_ssr_state diff --git a/packages/nextjs/src/pages/__tests__/ClerkProvider.test.tsx b/packages/nextjs/src/pages/__tests__/ClerkProvider.test.tsx index 85300518d2b..14bb5c185d0 100644 --- a/packages/nextjs/src/pages/__tests__/ClerkProvider.test.tsx +++ b/packages/nextjs/src/pages/__tests__/ClerkProvider.test.tsx @@ -39,7 +39,20 @@ describe('Pages Router ClerkProvider (server render)', () => { expect(html).toContain('child'); expect(notice).toHaveBeenCalledTimes(1); - expect(notice).toHaveBeenCalledWith({ publishableKey: DEV_KEY, disabled: false }); + expect(notice).toHaveBeenCalledWith({ publishableKey: DEV_KEY, disabled: false, keyless: false }); + }); + + it('flags keys that came from keyless mode', () => { + renderToStaticMarkup( + + child + , + ); + + expect(notice).toHaveBeenCalledWith(expect.objectContaining({ keyless: true })); }); it('passes the opt-out through when set as a prop', () => { From 306ee109820b6f7b27d5fd58e93c9ecc9db4c6b9 Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Fri, 4 Sep 2026 17:00:11 -0700 Subject: [PATCH 3/4] docs(nextjs): Correct the dev-key notice comment The comment claimed the notice prints in CI on purpose because sandboxed agents only see build output. Agent sandboxes do not set CI environment variables, so that reasoning did not hold. No filter was needed in the first place; the comment now explains only why the build-phase string is hardcoded. --- packages/nextjs/src/utils/devKeyNotice.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nextjs/src/utils/devKeyNotice.ts b/packages/nextjs/src/utils/devKeyNotice.ts index b0ed2a94de6..d9274c534b0 100644 --- a/packages/nextjs/src/utils/devKeyNotice.ts +++ b/packages/nextjs/src/utils/devKeyNotice.ts @@ -15,7 +15,7 @@ function isTerminalSafeInstance(value: string): boolean { return /^[a-z0-9.-]+$/i.test(value); } -// CI builds print on purpose (sandboxed agents only see build output); PHASE_PRODUCTION_BUILD is hardcoded to keep next/constants out of client bundles. +// PHASE_PRODUCTION_BUILD is hardcoded rather than imported from next/constants to keep that module out of client bundles. function isBuildOrDevServer(): boolean { if (typeof process === 'undefined' || !process.env) { return false; From e12d970e3a77c6dd9a0061bc4349095be0ae495f Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Fri, 4 Sep 2026 17:13:54 -0700 Subject: [PATCH 4/4] test(shared): Derive guidance text instead of hardcoding it The fatal-key test restated Clerk's whole guidance paragraph as a literal, so the canonical Dashboard link change on main broke it and tripped the legacy-link check. The test now reads the guidance from the thrown error and asserts both fatal paths share it and that it contains the init sentence. --- packages/shared/src/__tests__/keys.spec.ts | 27 ++++++++++++++-------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/shared/src/__tests__/keys.spec.ts b/packages/shared/src/__tests__/keys.spec.ts index 47688afc4e7..b0d09b00d1f 100644 --- a/packages/shared/src/__tests__/keys.spec.ts +++ b/packages/shared/src/__tests__/keys.spec.ts @@ -86,17 +86,24 @@ describe('parsePublishableKey(key)', () => { ); }); - it('keeps the full guidance text on fatal errors and shares the init sentence with it', () => { - const expectedGuidance = - 'To create a Clerk application with valid keys, in your terminal run:\n\nnpx clerk@latest init\n\n`npx clerk@latest init` creates a Clerk application and writes keys to your .env file. No Clerk account or login required and the command is non-interactive.\n\nIf you have a Clerk application, run `npx clerk@latest env pull` to write the keys (`--instance prod` for production keys). Or copy them from https://dashboard.clerk.com/last-active?path=api-keys.'; - - expect(() => parsePublishableKey(undefined, { fatal: true })).toThrowError( - `Publishable key is missing. ${expectedGuidance}`, - ); - expect(() => parsePublishableKey('fake_pk', { fatal: true })).toThrowError( - `Publishable key not valid (expected format: pk_test_... or pk_live_...). ${expectedGuidance}`, + it('appends the same guidance to every fatal error, and that guidance contains the init sentence', () => { + const messageFor = (key: string | undefined) => { + try { + parsePublishableKey(key, { fatal: true }); + } catch (error) { + return (error as Error).message; + } + throw new Error('expected parsePublishableKey to throw'); + }; + + const missingKeyMessage = messageFor(undefined); + const invalidKeyMessage = messageFor('fake_pk'); + + const guidance = missingKeyMessage.slice('Publishable key is missing. '.length); + expect(guidance).toContain(accountlessInitGuidance); + expect(invalidKeyMessage).toBe( + `Publishable key not valid (expected format: pk_test_... or pk_live_...). ${guidance}`, ); - expect(expectedGuidance).toContain(accountlessInitGuidance); }); it('applies the proxyUrl if provided', () => {