Articles

Stripe secret key in NEXT_PUBLIC_: find, fix and rotate it

Nov 7, 2026 · 7 min read · Stripe

Next.js copies the value of every NEXT_PUBLIC_ environment variable into the JavaScript it sends to the browser at build time. If a Stripe secret key (sk_live_...) or restricted key (rk_live_...) sits in one of those variables, every visitor to your site can read it. Move the key to a server-only variable, rebuild, and rotate it in the Stripe Dashboard, because the old value has already been public.

On this page8 sections

How NEXT_PUBLIC_ sends a variable to the browser

Next.js inlines NEXT_PUBLIC_ values into the client bundle during next build, replacing each process.env.NEXT_PUBLIC_... reference with the literal value. The Next.js docs say the value “will be inlined into any JavaScript sent to the browser.”

Variables without the prefix stay on the server. If client code references one, Next.js replaces it with an empty string. That’s a safeguard, and it also explains one common path to a leak: a Stripe call that runs in a client component fails because the key is empty, and adding NEXT_PUBLIC_ to the name makes the error go away. The key now ships to every browser.

Two details from the docs matter here. First, inlined values are frozen at build time, so removing the variable from your hosting settings does nothing until you rebuild and redeploy. Second, only direct references such as process.env.NEXT_PUBLIC_STRIPE_KEY get inlined; dynamic lookups like process.env[name] don’t.

Which Stripe keys are safe in client code

Only the publishable key is safe in client code. Stripe’s API keys page says publishable keys (pk_...) are made for Stripe.js, Elements and mobile SDKs, and “can’t perform sensitive operations such as creating charges or reading account data.”

Key Prefix Safe in the browser? What it can do
Publishable pk_live_, pk_test_ Yes Identify your account, create tokens or PaymentMethods
Restricted rk_live_, rk_test_ No Whatever permissions you gave it
Secret sk_live_, sk_test_ No Unrestricted access to all Stripe APIs
Webhook signing secret whsec_ No Proves an event came from Stripe

Stripe’s support page on compromised keys says someone holding your secret key could “impersonate your integration to conduct fraudulent activity”, which can mean financial losses, unauthorized payouts or changes to your account settings. The whsec_ signing secret is also server-only. Stripe’s webhook code uses it to verify that a request came from Stripe, so anyone holding it can sign events your endpoint will accept. That’s covered in Stripe webhook signature verification in Next.js.

Why Stripe flags frontend environment variables

Stripe lists frontend environment variable misuse first among the causes behind most of the compromised keys its security team sees. Its support page explains that modern frameworks bundle certain variables into files sent to browsers, and that a secret key in one of them “will be visible to anyone who visits your site.”

Stripe names the variable patterns to avoid by framework: REACT_APP_STRIPE_SECRET_KEY in Create React App, VITE_STRIPE_SECRET_KEY in Vite, NUXT_ENV_STRIPE_SECRET_KEY in Nuxt. For Next.js it warns more broadly that “any reference to a secret in page code may appear in initial server-rendered HTML.” So the check covers your HTML as well as the NEXT_PUBLIC_ names.

The same mechanism leaks Supabase keys. The Supabase secret key and legacy service_role key bypass all row-level rules, and the anon key vs service_role key guide shows how to tell which one you shipped.

How to find a Stripe key in your Next.js bundle

Build the app, then search the client output and your rendered HTML for Stripe secret and restricted key prefixes. The browser chunks are under .next/static, served at /_next/static/, so that folder is what matters. The server folder is expected to reference your key by variable name.

An example script, for your own project:

Terminal
# Example: look for Stripe server-side keys in your own Next.js build.
next build

# 1. Client bundle: any match here is public.
grep -rEo '(sk|rk)_(live|test)_[A-Za-z0-9]{10,}' .next/static | sort -u

# 2. Source: variables that would be bundled and look secret.
grep -rnE 'NEXT_PUBLIC_[A-Z_]*(SECRET|SK_|PRIVATE|RESTRICTED|SERVICE)' \
  --include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' \
  --include='.env*' . --exclude-dir=node_modules --exclude-dir=.next

# 3. Rendered HTML of your own live pages.
curl -s https://your-app.example/ | grep -Eo '(sk|rk)_live_[A-Za-z0-9]{10,}'

You can also open DevTools on your site, go to the Sources panel, and search all files for sk_live and rk_live. Do this on the deployed site as well as locally, since a value set only in your hosting provider’s settings never appears in a local build. A test key (sk_test_) in the bundle still needs fixing: it shows the code path is wrong, and a live key would take the same path. Check git history too: Stripe’s best-practices guide says bad actors “continuously scan public repositories for Stripe API keys”, and it recommends a secret scanner plus a pre-commit hook.

How to fix the code

Keep every Stripe call that needs a secret or restricted key in server code: a Route Handler, a Server Action or another backend. Read the key from a variable with no NEXT_PUBLIC_ prefix, and let the browser talk to your route instead of to Stripe.

Next.js has a guard for this. Importing server-only at the top of a module makes the build fail if a Client Component imports it, according to the Next.js docs. An example layout:

TypeScript
// lib/stripe.ts (example)
import 'server-only'
import Stripe from 'stripe'

// STRIPE_SECRET_KEY has no NEXT_PUBLIC_ prefix, so it stays on the server.
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
TypeScript
// app/api/checkout/route.ts (example)
import { stripe } from '@/lib/stripe'

export async function POST() {
  const session = await stripe.checkout.sessions.create({
    mode: 'payment',
    line_items: [{ price: process.env.STRIPE_PRICE_ID!, quantity: 1 }],
    success_url: 'https://your-app.example/thanks',
    cancel_url: 'https://your-app.example/pricing',
  })
  return Response.json({ url: session.url })
}

The client only gets a Checkout URL, and the price comes from the server. Stripe also recommends restricted keys in place of the unrestricted secret key for most uses, scoped to the resources each part of your app needs. Its docs say it doesn’t recommend secret keys for new use cases.

How to rotate a leaked Stripe secret key

Rotate the key as soon as you find it, even if you can’t tell whether anyone used it. Stripe’s guidance is to treat any exposure as a potential compromise.

The steps, from Stripe’s support page:

  1. In the Dashboard, go to Developers, then API keys, and find the exposed key.
  2. Choose “…” then Rotate key. This creates a new key and sets the old one to expire. Choose an expiration long enough to test the new key, and no longer.
  3. Put the new key in your server environment, not a NEXT_PUBLIC_ variable, then rebuild and redeploy so the old value leaves the bundle.
  4. Confirm the integration works. “View request logs” on the new key shows whether your app uses it.
  5. Delete the compromised key. Stripe says deletion takes effect immediately and request logs stay viewable.
  6. Review the old key’s recent activity in Workbench, and look for connected accounts, payouts, bank accounts or charges you don’t recognize.
  7. Rotate your other secret and restricted keys in case more than one leaked.

Stripe adds one more point: if you discover a new exposure during cleanup, rotate again after fixing it, because the replacement may have leaked the same way.

To limit the damage of the next leak, Stripe suggests restricting keys to your servers’ IP addresses and reviewing each key’s request logs regularly.

When the leak is your host’s

Your keys can also leak from your hosting provider. On October 21, 2026, Railway disclosed that an unauthorized party may have accessed encrypted environment variables and, for some, obtained the decrypted contents. Railway recommended that all users roll every environment variable stored there, including API keys.

Stripe recommends practicing rotation ahead of time, so you know where each key is used and can replace it on short notice when a disclosure like this lands.

Shipmate’s free check is passive and needs your written permission. It looks at the JavaScript and HTML your live site already sends to visitors for keys that shouldn’t be there. The Launch Lockdown covers the rest of your Stripe setup, including webhook signatures and server-side prices, with fixes delivered as a tested pull request.

Sources