hackymacky

Notes

← Blog

The Clerk publishable key belongs in the browser. The secret key does not.

15 Oct 2026 · 1397 words

Clerk's pk_ key is safe in a Next.js client. The sk_ secret key and the whsec_ webhook secret are not. How the prefixes collide with Stripe, and how to rotate a leaked secret key.

Clerk apps ship two keys, and only one of them is allowed in the browser. The publishable key is how the client finds your Clerk Frontend API. The secret key is how your server calls Clerk's Backend API. Mixing them up is a one-line environment-variable mistake, and Next.js will happily inline the wrong one.

This is the check to run on a Next.js app before a preview URL goes out: which prefix is in the client bundle, which variable name held it, and what to rotate if the secret moved.

The two keys Clerk documents

Clerk's environment-variable guide splits them by framework.

For Next.js, NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY is the publishable key. It is pk_test_ on a development instance and pk_live_ on a production instance. CLERK_SECRET_KEY is the secret key. It is sk_test_ or sk_live_. The guide says not to expose the secret on the frontend with a public environment variable.

Vite's variable is VITE_CLERK_PUBLISHABLE_KEY. There is still no public prefix on the secret.

The production deploy page says the same thing. The publishable key is passed into <ClerkProvider>. The secret key is used for the Backend API. Both should come from environment variables, and a production deploy should use the production pair, not the development pair.

The rotation guide is the one that states the exposure rule in plain language.

  • Publishable key (pk_live_ / pk_test_): safe to expose on the frontend. You do not need to rotate it because it was committed or otherwise made public.
  • Secret key (sk_live_ / sk_test_): must stay private. If it is exposed, rotate it.
  • Webhook signing secret (whsec_): a shared secret. Anyone with it can send your webhook endpoint a request that passes verification. If it is exposed, rotate it too.

A pk_ key in client JavaScript is the design. An sk_ key or a whsec_ value in that same JavaScript is the incident.

Why the publishable key is in the client

Clerk's overview of how the product works explains the publishable key's shape. The client needs your Frontend API URL. The key is that URL, base64-encoded, with pk_test_ or pk_live_ in front and a $ suffix inside the decoded value. Older Clerk versions put the Frontend API host in the clear. The key format replaced that.

You can see it with the example from that page. pk_test_ZXhhbXBsZS5hY2NvdW50cy5kZXYk decodes, after the prefix, to a host ending in accounts.dev. That host is not a credential. It is the address the browser uses to reach your instance. Production keys are tied to the domains you configured. The troubleshooting note on production keys says pk_live_ and sk_live_ do not work on localhost. They work on the production domain.

So a bundle search that only says "Clerk key found" is unfinished. Read the prefix.

How Next.js puts the secret in the bundle

Next.js inlines NEXT_PUBLIC_ values at next build and leaves every other process.env name on the server. The environment-variables guide says non-public variables are not available in the browser, and that a public variable is replaced with a hard-coded string in the JavaScript that is sent to the client. The value is frozen at build time. A later change in the host's dashboard does not rewrite chunks that are already built. The longer version of that mechanism is the client-bundle post.

Three ways a Clerk secret takes that path:

The variable is renamed. NEXT_PUBLIC_CLERK_SECRET_KEY or VITE_CLERK_SECRET_KEY tells the bundler to ship it. Clerk's name for the secret has no public prefix. Adding one is the bug.

The secret is copied into the publishable variable. The provider then receives sk_live_…. The client still loads. The secret is now in the bundle, and the publishable key is missing.

A client module reads process.env.CLERK_SECRET_KEY only when something else also inlines it. A bare process.env.CLERK_SECRET_KEY in a Client Component, with no NEXT_PUBLIC_ prefix and no env entry in next.config, does not become the secret in the browser. Next.js leaves it empty on the client. The env key in next.config is different: those values are bundled. env: { CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY } writes the secret into the client build. Do not use that key for Clerk's secret.

Server Components, Route Handlers, middleware, and the Clerk server helpers can read CLERK_SECRET_KEY. A file with "use client", or a shared module imported by one, cannot.

Look at the built files

After next build:

bash

rg -n "sk_live_|sk_test_|whsec_|NEXT_PUBLIC_CLERK_SECRET|CLERK_SECRET_KEY" .next/static public app src || true

A pk_test_ or pk_live_ hit in .next/static is expected if the app uses Clerk on the client. An sk_test_, sk_live_, or whsec_ hit in .next/static or public/ is the leak. A hit only under app/ in a file that is clearly a server module is source you should still move, but it is not yet in the downloaded bundle. Confirm by searching the build output, not only the source.

On a deployed preview, list the chunks the HTML points at and search those bodies:

bash

curl -sS https://your-preview.example.com | rg -o '/_next/static/[^" ]+\.js'

Download the paths you got and run the same rg. If the secret is in a response, it has been public since that deploy.

The prefix is also Stripe's

Stripe secret keys use sk_live_ and sk_test_ as well. A grep for sk_live_ in a Next.js app can be a Stripe secret, a Clerk secret, or both. The surrounding name is what tells you which dashboard to open.

Clerk's value sits next to CLERK_SECRET_KEY, clerkMiddleware, or @clerk/. Stripe's value sits next to STRIPE_SECRET_KEY, sk_live_ assigned to a Stripe client, or a whsec_ that Stripe also uses for webhook signing. Both products use a whsec_ prefix for webhook secrets. Read the file the match is in before you rotate the wrong product.

Do not assume a hit is harmless because the app uses Clerk's publishable key nearby. Apps often have both a pk_ and an sk_ in the repo. Only the sk_ and the webhook secret are private.

Rotate the secret, then rebuild

Clerk's rotation guide lets one instance hold more than one secret key, so you add the replacement before you delete the leaked one. Development and production are separate instances. Rotating one does not rotate the other. Pick the instance with the app and environment selectors at the top left of the dashboard.

  1. Open the API keys page for that instance.
  2. Under Secret keys, add a key and name it.
  3. Put the new value in CLERK_SECRET_KEY everywhere that variable is set: local env files, the host, and CI. Do not add a NEXT_PUBLIC_ name for it.
  4. Redeploy so the server process reads the new key.
  5. Run a server path that calls the Backend API, such as a route that uses clerkClient. On the API keys page, the new key should show recent use and the old key should not.
  6. Delete the old key only after that check. Deleting a key the running deploy still sends will fail those Backend API calls. A key that reached a client bundle should be treated as copied.

A leaked whsec_ is rotated on the Webhooks page, not by editing the old endpoint in place. Add a new endpoint with the same URL and the same events, copy its signing secret into CLERK_WEBHOOK_SIGNING_SECRET, redeploy, and confirm a test event verifies. While both endpoints exist, Clerk delivers each event to both. Delete the old endpoint after the new one verifies. A publishable key that was only public does not need either rotation.

Production keys stay on the production instance. If the bundle you searched was a preview deployment, check which Clerk instance that deployment's env pointed at. Previews often still hold the development sk_test_ key. That key is still a secret. It is not "only test" in the sense that it can be committed.

What a clean client looks like

The browser bundle may contain pk_test_ or pk_live_ and the Frontend API host baked into that key. It should not contain sk_test_, sk_live_, or whsec_. The server env has CLERK_SECRET_KEY with no public prefix. next.config does not list that name under env.

The same split shows up on other client SDKs. Firebase's web apiKey is a project identifier, and the secret is a service-account private key. That case is the Firebase post. Clerk's version is stricter on the string itself: pk_ is fine in the client, and sk_ is not, no rules engine required.