The short version
- Publishable key (
pk_…) is safe in the browser. It identifies your account for Stripe.js / Elements. - Secret & restricted keys (
sk_…/rk_…) are server only. Never in a client component, never behindNEXT_PUBLIC_. - The fix: browser asks your server → your server talks to Stripe → browser only gets a Checkout URL or a
client_secretfor one payment. - If it shipped: roll the key in Stripe first, then fix the code. Reversing the order leaves the old key live while you refactor.
1. Know your keys: only one belongs in the browser
Stripe gives you different keys for different trust levels. Mixing them up is the whole problem. Per Stripe’s docs, only publishable keys are safe to expose outside your backend; you’re responsible for protecting every other key, including restricted keys. Stripe: API keys
Publishable key. Loads Stripe.js, tokenizes card details, confirms a PaymentIntent that your server created. It cannot create charges on its own or read your account data.
Secret key. Unrestricted API access. Stripe now recommends migrating most uses to restricted keys, but even a restricted key (rk_…) is still server-only. Scoped is not the same as public.
| Prefix | What it is | Browser? | Notes |
|---|---|---|---|
| pk_test_ / pk_live_ | Publishable | Yes | Use as NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY |
| sk_test_ / sk_live_ | Secret | Never | Full account access; treat it like a root password |
| rk_test_ / rk_live_ | Restricted | Never | Server-only, limited to permissions you grant |
| whsec_… | Webhook signing secret | Never | Verifies webhooks really came from Stripe |
Test vs. live matters too: _test_ keys only touch sandbox data and _live_ keys touch real money, but a leaked test secret in your bundle is still a red flag. It usually means the live one is one env-var swap away from the same mistake.
2. How it leaks in vibe-coded apps
AI coding tools optimize for “it works.” If a Stripe call fails in the browser, the fastest patch is to move the key where the browser can reach it. These three patterns are the most common:
Pattern A: the NEXT_PUBLIC_ trap
In Next.js, any env var starting with NEXT_PUBLIC_ is inlined into the JavaScript sent to every visitor. Naming your secret NEXT_PUBLIC_STRIPE_SECRET_KEY doesn’t make it a different key. It publishes it.
Pattern B: Stripe initialized in a client component
Any file with "use client", and anything it imports, can end up in the client bundle. If that file imports stripe (the server SDK) and reads STRIPE_SECRET_KEY, the key (or the code path holding it) ships.
Pattern C: the key in GitHub
Hardcoded fallback like const key = process.env.X || "sk_live_…", or a committed .env / .env.local. Even if you delete it later, it lives in git history and in any fork or clone.
// ❌ This whole file runs in the browser
"use client";
import Stripe from "stripe";
// ❌ Secret key in client code: anyone can View Source and take it
const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY!);
export default function Pricing() {
async function buy() {
const session = await stripe.checkout.sessions.create({ /* … */ });
window.location.href = session.url!;
}
return <button onClick={buy}>Buy</button>;
}// ✓ Browser never sees the secret. It only asks YOUR server
"use client";
export default function Pricing() {
async function buy() {
const res = await fetch("/api/checkout", { method: "POST" });
const { url } = await res.json();
window.location.href = url;
}
return <button onClick={buy}>Buy</button>;
}3. Check if you’re exposed (60 seconds)
Search your live site
Open your deployed app, View Source, and open the JS files under /_next/static/. Search each for sk_live, sk_test, rk_live, and whsec_. Any hit = exposed.
Search your codebase
Run this in your project root. It catches hardcoded keys and the NEXT_PUBLIC_ mistake:
grep -rE "sk_(live|test)_|rk_(live|test)_|whsec_" --exclude-dir=node_modules .
grep -r "NEXT_PUBLIC_.*STRIPE_SECRET\|NEXT_PUBLIC_.*SK_" --exclude-dir=node_modules .Paste a snippet into the checker
Drop an env file, a component, or a chunk of built JS below. This runs entirely in your browser, and nothing is sent anywhere. Never paste a full live key into any online tool, including this one; a prefix snippet is enough to test the pattern.
4. The fix: move every secret-key call server-side
The rule is simple: the browser should never need to prove it’s you to Stripe. Your server proves it (with the secret key), creates the thing, and hands the browser a one-time URL or client secret.
Fix your env vars
Only the publishable key gets the NEXT_PUBLIC_ prefix. Everything else stays unprefixed, so Next.js keeps it server-only.
# ✓ Safe to expose: publishable only
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
# ✓ Server-only: NO NEXT_PUBLIC_ prefix
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...On Vercel / Railway / Netlify, add the same names in the dashboard’s Environment Variables, and confirm .env* is in your .gitignore (keep a .env.example with empty values).
Create one server-only Stripe file
Centralizing it makes it auditable: if this file is the only place the secret is read, you only have one place to protect. The server-only import makes the build fail if a client component ever imports it, which is exactly what you want.
import "server-only";
import Stripe from "stripe";
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error("STRIPE_SECRET_KEY is not set");
}
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);Create the Checkout Session in a Route Handler
This is the workhorse pattern for one-time payments and subscriptions. Validate on the server, don’t trust amounts or price IDs sent from the browser without checking them against your own list.
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
export async function POST() {
const session = await stripe.checkout.sessions.create({
mode: "payment", // or "subscription"
line_items: [{ price: "price_...", quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
});
return NextResponse.json({ url: session.url });
}Using a Server Action instead? Same rule: the file starts with "use server", imports @/lib/stripe, creates the session, and redirect(session.url). Either is fine; a Route Handler is easier for non-Next clients to call.
For custom payment forms, return only the client_secret
If you use the Payment Element, your server creates the PaymentIntent and returns only its client_secret. That secret is scoped to that one payment. It is not your account key and it’s designed to reach the browser.
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
export async function POST() {
const intent = await stripe.paymentIntents.create({
amount: 1900, // $19.00: calculate this on the server, never trust the client
currency: "usd",
automatic_payment_methods: { enabled: true },
});
return NextResponse.json({ clientSecret: intent.client_secret });
}Verify webhooks with the raw body
Never grant access because the browser landed on /success. Fulfill orders only when a signature-verified webhook says the payment succeeded. Verification needs the raw request text. Parsing it as JSON first breaks the signature check.
import { headers } from "next/headers";
import { stripe } from "@/lib/stripe";
export async function POST(req: Request) {
const body = await req.text(); // raw, NOT req.json()
const sig = (await headers()).get("stripe-signature")!;
const event = stripe.webhooks.constructEvent(
body, sig, process.env.STRIPE_WEBHOOK_SECRET!
);
if (event.type === "checkout.session.completed") {
// Fulfill the order here. This is the signal you can trust
}
return new Response("ok");
}Rebuild and re-check: env-var changes need a fresh deploy. After deploying, repeat Step 3’s live-site search. pk_live_… in the bundle is expected. sk_, rk_, or whsec_ is not.
5. Already leaked? Rotate first, refactor second
Deleting the key from your code does not un-leak it. Anyone who copied it still has it until you roll it in Stripe. Do these in order:
Roll the key in Stripe
Dashboard → Developers → API keys → … next to the secret key → Roll key. You can set a short grace period for the old key while you deploy the new one (hours, not days), or expire it immediately if you see unfamiliar activity.
Review what happened while it was out
Check Dashboard → Developers → Logs and your payments / refunds list for anything you don’t recognize. Look for new API keys, webhook endpoints, or payout changes you didn’t make.
Deploy the new key server-side
Put the new secret in your host’s env vars (no NEXT_PUBLIC_), apply the fix in Section 4, redeploy, and confirm checkout still works in test mode before switching live keys.
Scrub the repo
Remove the key from code and, if it was committed, treat history as compromised even after a rewrite. Rotation (Step 1) is what protects you. Add secret-scanning / push protection in GitHub if available.
6. Harden it further
Prefer a restricted key for your app
Create a restricted key (rk_live_…) with only the permissions your server needs (for a typical checkout flow, write access to Checkout Sessions / PaymentIntents and read access to the objects you reconcile), and use it instead of the unrestricted secret. If it ever leaks, the blast radius is smaller. It still never goes in the browser.
One key per environment, one per app
Don’t reuse the same secret across staging, a side project, and production. Separate keys mean you can roll one without breaking everything, and Stripe’s logs tell you which app made a call.
Don’t log keys, don’t paste them into AI chats
Never console.log(process.env), never include a real key in a prompt, screenshot, or support ticket. If a key appears in a log or chat, treat it as leaked and roll it.
Stripe key checklist
- Only
pk_…has aNEXT_PUBLIC_prefix. Search env names forNEXT_PUBLIC_+SECRET/SK_returns nothing. - No
sk_,rk_, orwhsec_in live JS. Searched the deployed/_next/static/files and found nothing. - Stripe SDK lives in one
server-onlyfile. No client component importsstripeorlib/stripe. - Checkout / PaymentIntents are created on the server. Browser only receives a Checkout URL or a single-payment
client_secret. - Amounts are calculated server-side. The price the user pays never comes from a value the browser sent unchecked.
- Webhooks verify signatures with the raw body and are the only trigger for fulfillment, not the success redirect.
- Any key that ever shipped has been rolled in Stripe, and Stripe Logs were reviewed for unfamiliar activity.
.env*is gitignored and a restricted key is used in production where practical.
Not sure if your live site is leaking it?
ShipReady scans your deployed URL and checks the JavaScript your visitors actually download for sk_live, rk_live, OpenAI keys and Supabase service keys. It also checks the OG image, title and API-route basics. Public checks run on any URL; secret findings unlock only after you verify you own the site.
FAQ
Is it okay that my publishable key is visible in View Source?
Yes, that’s what it’s for. pk_live_… identifies your account to Stripe.js and can’t create charges or read account data by itself. Worry about sk_, rk_, and whsec_, not pk_.
My app “needs” the secret key in the browser to create a payment. What now?
It doesn’t. It needs a Checkout Session or a PaymentIntent, and only your server can create those safely. Move that one call into a Route Handler or Server Action (Section 4) and have the browser call your endpoint instead of Stripe directly.
I used a test key (sk_test_) in the bundle. Do I still need to rotate?
Rotate it anyway and fix the pattern. Test keys can’t move real money, but the same code path will leak the live key the moment you swap env vars for launch.
What about Lovable / Bolt / Replit apps without an obvious “server”?
Use their backend layer (Supabase Edge Functions, serverless functions or the platform’s secrets store) to hold the secret and make Stripe calls. If a tool truly has no server layer, use Stripe Payment Links or Checkout via a small serverless function; don’t fall back to putting sk_live_ in frontend code.
Does hiding the key in an API route make the route safe by itself?
No. Anyone can call your /api/checkout endpoint, so validate the request, require auth where it makes sense, calculate amounts server-side, and rely on verified webhooks for fulfillment. Keeping the key server-side protects the key; your route still needs its own checks.
Keep reading
Sources: Stripe Documentation: API keys. Code patterns follow Stripe’s Checkout Sessions / PaymentIntents model: create on the server, confirm in the browser, fulfill on verified webhooks.