KeyDrift
Free scan
criticalNext.js·Stripe secret key

NEXT_PUBLIC_ leaked your stripe secret key into the App Router bundle

Charge cards, issue refunds, and read every customer record on the account.

Why Next.js does this

A server component can read `process.env.STRIPE_SECRET_KEY` safely. Move that same line into a client component and the build fails to find it, so the quickest fix — rename it with `NEXT_PUBLIC_` — is also the one that inlines it into the JavaScript every visitor downloads.

Confirm it first

Before rotating anything, check whether the key is actually being served. Paste your deployed URL — KeyDrift downloads the same JavaScript a visitor gets and tells you what is in it.

No account. Read-only — the scanner only ever issues GET requests, and never stores a key: findings carry a masked prefix and a fingerprint.

Rotate the key

Do this before changing any code. The key has been served to browsers, cached by CDNs and very likely scraped already — removing it from the source does not un-publish it.

  1. 1Roll the key in the Stripe dashboard. Rolling issues a replacement and revokes the old key.
  2. 2Review recent charges, refunds and payouts for anything you did not initiate.
  3. 3Move the Stripe call into a server route. The browser only ever needs the publishable key.
Open the revocation page

Move the call to a server

The replacement key must not follow the old one into the bundle, which means the code that uses it cannot live in the browser. Any variable named NEXT_PUBLIC_ is inlined at build time by design — the prefix is the mechanism, not a mistake.

Before — shipped to the browser

// app/components/Chat.tsx  ("use client")
// NEXT_PUBLIC_ inlines this into the browser bundle.
const key = process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY;
const result = await new Stripe(key).checkout.sessions.create(body);

After — stays on a route handler or server action

// app/api/checkout/route.ts  — runs on the server only
import 'server-only';

export async function POST(request: Request) {
  const key = process.env.STRIPE_SECRET_KEY; // no NEXT_PUBLIC_ prefix
  const result = await new Stripe(key).checkout.sessions.create(body);
  return Response.json(result);
}

// app/components/Chat.tsx  ("use client")
const result = await fetch('/api/checkout', { method: 'POST' }).then((r) => r.json());

How KeyDrift detects it

Matches `sk_live_` and `sk_test_`, then reads the surrounding code. Clerk issues secret keys in exactly this format and nothing in the string distinguishes them, so when only Clerk is referenced nearby the finding is attributed to Clerk, when both are the finding names both, and a `sk_test_` key is reported at medium because no real money can move.

It will happen again

A server component can read `process. That has not changed because you fixed this one file — the next feature request produces the same shape of code. Continuous monitoring re-scans every deploy and tells you the moment a key comes back.