Skip to content
StackSiren

My Stripe secret key is exposed. What should I do?

By StackSiren · Published

Roll the key in the Stripe Dashboard immediately, review recent payments and API activity for anything you don't recognise, and only use secret or restricted keys in server code. A publishable pk_ key is safe in the browser; an sk_ or rk_ key is not.

Stripe's own key docs draw that line clearly: publishable keys can go in front-end code; secret and restricted keys must stay on your backend. AI-built apps on Lovable, Bolt, Cursor, v0 and Vercel often put the wrong one in a VITE_ or NEXT_PUBLIC_ variable. Fix the key first, then the cause — or the replacement leaks the same way.

Roll the key

Only act on accounts and sites you own.

  1. Sign in to the Stripe Dashboard and open Developers → API keys. Confirm you are in the right mode (sandbox vs live). Live keys start with sk_live_ / rk_live_ / pk_live_; sandbox keys use _test_.
  2. Find the compromised secret or restricted key. Open its overflow menu (⋯) and choose Rotate key.
  3. Pick an expiration for the old key. Stripe's rotation docs say both keys can work for up to 7 days so you can migrate without downtime. If you choose Now, the old key is deleted immediately — use that when you know the key is already being abused.
  4. Copy the new key value once and store it in a password manager or your host's secrets vault. You can't retrieve a live-mode secret or restricted key you created yourself after you hide it.
  5. Update every server that used the old key (API routes, Edge Functions, workers, cron). Redeploy if the host bakes env vars at build or deploy time.
  6. Watch request volume on the old key (overflow menu → View request logs). Expire it once traffic is at zero, if you didn't expire it immediately.

If you can't tell which key leaked, rotate every secret and restricted key that project used and create fresh ones. Don't put the new value back into a VITE_, NEXT_PUBLIC_ or other browser-bundled variable.

Check events and payments

A leaked secret key can create charges, refunds, Customers, payouts and webhook endpoints — anything the Stripe API allows for that key. Restricted keys are limited to the permissions you set, but they can still hurt you inside that scope.

Work through this on your own account:

  1. Payments and Customers. In the Dashboard, scan recent successful and failed payments, refunds and new Customers for amounts, cards or emails you don't recognise.
  2. Workbench / request logs. Open logs for the compromised key and look for unusual endpoints, IP ranges or bursts outside your app's normal hours.
  3. Webhooks. Check that nobody added a rogue endpoint pointing at a domain you don't control.
  4. Connect / billing / disputes (if you use them). Review new connected accounts, transfers and unexpected dispute activity so you have a timeline for support.

We can't tell you what Stripe will decide about disputed abuse charges; contact support with the key prefix, rotation time and what you found. Rotation is what stops further API use under that key.

Prefer restricted keys over unrestricted secret keys

Stripe recommends restricted API keys (rk_test_… / rk_live_…) for new server integrations instead of unrestricted secret keys (sk_…). A restricted key only gets the resources and Read / Write / None permissions you assign. If it leaks, the blast radius is that permission set — not every Stripe API.

Migration pattern from Stripe's restricted-key docs:

  1. Create a restricted key with the minimum permissions your server needs (for example Charges Write, Customers Read, nothing for Payouts).
  2. Point one service at the restricted key and confirm payments still work.
  3. Rotate or expire the old unrestricted secret key once nothing uses it.

You can also attach an access policy (IP allowlist or advanced ASN/country rules) to live-mode keys. That doesn't replace rotation after a leak, but it shrinks the window for the next one.

Webhook signing secrets are separate

Webhook signing secrets (whsec_…) are not API keys. They prove that a POST to your endpoint came from Stripe. Each Dashboard (or CLI) endpoint has its own secret.

If a webhook secret leaked, or you suspect someone can forge events:

  1. Open Workbench → Webhooks, select the endpoint, open ⋯ → Roll secret.
  2. Choose immediate expiry or delay up to 24 hours while you update the server (Stripe's docs: multiple secrets stay active until the old one expires).
  3. Put the new whsec_… only in server environment variables.
  4. Verify signatures with the raw request body, the Stripe-Signature header and the endpoint secret — for example stripe.webhooks.constructEvent(...) in the official libraries. Don't parse JSON before verification; body parsers that re-serialise break the signature.

CLI stripe listen secrets differ from Dashboard endpoint secrets — mixing them up causes verification errors, not a leak by itself.

Move every secret server-side

The browser should only ever see a publishable key (pk_…) for Stripe.js, Elements or Checkout. Secret and restricted keys belong in server code.

Next.js (v0, Cursor, many Vercel apps) — app/api/checkout/route.ts:

import "server-only";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// Pin apiVersion explicitly if your project already does; otherwise the SDK default applies.

export async function POST(req: Request) {
  // Authenticate the user here before creating anything billable.
  const { priceId } = await req.json();
  const session = await stripe.checkout.sessions.create({
    mode: "payment",
    line_items: [{ price: String(priceId), quantity: 1 }],
    success_url: "https://yourapp.com/success",
    cancel_url: "https://yourapp.com/cancel",
  });
  return Response.json({ id: session.id, url: session.url });
}

Supabase Edge Function (common with Lovable / Bolt):

import Stripe from "https://esm.sh/stripe@17?target=deno";

Deno.serve(async (req) => {
  const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!);
  // Verify the caller (JWT / session) before creating a PaymentIntent.
  const paymentIntent = await stripe.paymentIntents.create({
    amount: 2000,
    currency: "gbp",
    automatic_payment_methods: { enabled: true },
  });
  return Response.json({ clientSecret: paymentIntent.client_secret });
});

Store the key with supabase secrets set STRIPE_SECRET_KEY=... (or your host's secret store). Never prefix it VITE_ or NEXT_PUBLIC_.

Then:

  1. Remove every front-end reference to sk_ / rk_ and any dangerouslyAllowBrowser-style Stripe init.
  2. Require a signed-in user (or other auth) on endpoints that create charges; rate-limit them.
  3. Rebuild, redeploy and re-scan the live site.

Find how it leaked

Only check repositories and production URLs you own. Work through these in order — same pattern as a leaked OpenAI key or an exposed Supabase service_role key.

A. Front-end JavaScript

SITE=https://yourapp.com
curl -s "$SITE/" | grep -oE '(/assets/[^"]+\.js|/_next/static/chunks/[^"]+\.js)' | sort -u | while read js; do
  curl -s "$SITE$js" | grep -qE 'sk_(live|test)_[A-Za-z0-9]+|rk_(live|test)_[A-Za-z0-9]+' && echo "Possible Stripe secret in $js"
done

VITE_* and NEXT_PUBLIC_* values are copied into the browser bundle at build time — see are VITE_ variables secret? and is NEXT_PUBLIC_ safe?. Our exposed API key checker automates the scan.

B. Public .env or .git

curl -s -o /dev/null -w "%{http_code}\n" https://yourapp.com/.env
curl -s -o /dev/null -w "%{http_code}\n" https://yourapp.com/.git/config

A 200 with real secret content is a leak (some SPAs return 200 + index.html for every path — read the body). Details: is my .env file public? and the exposed .env / .git guide.

C. Git history, paste sites, shared projects

git log -p --all -S 'sk_live_' -- . ':(exclude)node_modules' | head

Also search screenshots, Discord, support tickets and AI chat logs for the key prefix. Revocation protects you; rewriting Git history is optional cleanup. See the exposed API keys guide and, on Replit, how Secrets behave when shared.

FAQ

Is the publishable key (pk_) a problem if it's in my JavaScript? No. Stripe designs publishable keys for the browser. They can't create charges or read sensitive account data the way a secret key can. Still don't commit live keys into public repos if your process prefers keeping them in env config — but a pk_ in the client is expected.

Should I rotate if the key only appeared in a private repo? If anyone who shouldn't have billing power could see it, or the repo was ever public / forked / copied, rotate. Private today is not private forever.

Restricted key vs secret key after a leak? Rotate the leaked key either way. When you replace it, prefer a restricted key with least privilege so the next incident is smaller.

Do I need to roll webhook secrets too? Only if the whsec_ value was exposed or you suspect forged events. API key rotation and webhook secret rolling are separate Dashboard actions.

Will Stripe refund fraudulent charges made with my key? We don't know and can't promise anything. Contact Stripe support with evidence; their decision follows their policies.

How do I get warned next time? Scan after every deploy. StackSiren's first scan is free and passive. Paid plans add scheduled rescans of sites you own (Solo $29/mo, Builder $79/mo). For a deeper pass over one site, Deep Scan is $49 one-off or $99/mo as an add-on.

Check your app now

Free passive scan, results in under a minute.

Run a free scan

Fix guides for this topic

Platform checklists: Lovable · Bolt · Cursor · v0 · Vercel

Related posts

Sources