Skip to content
Back to field notes
SecretsJune 6, 20266 min read

Stripe publishable key vs secret key: what the frontend is allowed to know

Stripe issues two keys with opposite trust levels. If your secret key ships in the client bundle, anyone who opens the browser can use it. This note shows how to check before you share the URL.

SecretsStripePayments

The secret key was never meant to reach the browser

Stripe gives your app two keys, and they sit on opposite sides of a trust boundary. A Stripe secret key exposed in the frontend means anyone who opens the browser can act as your Stripe account. That is the concrete launch risk this note is about, and it is one of the clearest launch blockers there is.

The publishable key has the prefix pk_ (you will see pk_live_ in production and pk_test_ in test mode). It is designed for the browser. It can do safe client operations like mounting a payment element or creating a token from card details the customer typed. It cannot read your customer list, issue refunds, or move money. Stripe built the publishable flow so that the worst a public pk_ key can do is start a payment that still has to clear server-side and through Stripe's own fraud checks.

The secret key has the prefix sk_ (sk_live_ in production, sk_test_ in test mode). It authenticates full Stripe API calls. With it you can list customers, read payment history, create refunds, charge saved cards, and move money out. It also bypasses the client-side fraud protections Stripe layers around the publishable flow, because it is the trusted, server-side credential. It must live only on your server.

  • Publishable key (pk_): safe in the browser by design, limited to client operations.
  • Secret key (sk_): full account access, server-only, never ships to a client.
  • The failure mode: the secret key gets wired into a client-exposed variable and lands in the public bundle. At that point the key is not leaked through a clever attack. It is published.

Why AI-built apps wire the secret key into the bundle

When you ask an AI tool to "add Stripe payments," it has to put a key somewhere. Stripe's server SDK needs the secret key to create a payment intent or a checkout session, and the fastest path to a working demo is to drop that key into the same environment file the frontend already reads.

In a Vite app that variable might be named VITE_STRIPE_SECRET_KEY. In a Next.js app it might be NEXT_PUBLIC_STRIPE_SECRET_KEY. Both prefixes (VITE_ and NEXT_PUBLIC_) tell the bundler to inline that value into the JavaScript that ships to the browser. The app then works on the first try: a payment goes through in test mode, the checkout opens, the demo looks finished. The thing that did not happen is the step where someone confirms which key belongs on which side of the boundary.

  • The generated code calls Stripe and a charge succeeds, so the wrong key placement is invisible from inside the app.
  • The naming is the trap: a VITE_ or NEXT_PUBLIC_ prefix is a publish instruction, not a safety label. Putting a secret behind it inlines the secret.
  • Fast deploy means the public URL, and the bundle, exist before anyone audits which strings the build shipped. This is the same class of mistake covered in the note on Vite env variables exposed in the client bundle.

Check your built bundle for sk_ strings

You can confirm this on your own app without any attack, because the bundle is already public. There are two reliable paths, and both look for the same shapes: sk_live_, sk_test_, or a variable name like STRIPE_SECRET.

From the browser: open your deployed app, open DevTools, and go to the Network tab. Reload, then look at the downloaded JavaScript files. Use the search across responses (in Chrome, the Search panel, Ctrl+F or Cmd+F across all sources) and search for sk_live_, then sk_test_, then STRIPE_SECRET. You are searching the files an outside visitor downloads, so anything you find here is reachable by anyone.

From a build artifact: run your production build locally and search the output folder (for example dist or .next) for the same three strings. A command-line search such as grep for sk_live_ across the build output is the fastest version of this check.

  • Suspicious result: a string starting sk_live_ or sk_test_ appears in any file the browser fetched. That secret has shipped to every visitor. Treat it as compromised.
  • Safer result: the only Stripe key you find is a pk_ value (pk_live_ or pk_test_). The publishable key is meant to be there, so that is the expected, non-blocking case.
  • Do not paste a real key into this note, a screenshot, or a ticket while investigating. Match on the prefix shape, not the full value.

Move the secret key server-side, and rotate it if it shipped

The fix has two parts: get the secret key off the client, and clean up if it was ever exposed.

Keep the secret key in a server-only variable, one with no VITE_ or NEXT_PUBLIC_ prefix, read only by code that runs on the server. Make every Stripe call that needs the secret key go through a server route, API route, or edge function. The browser talks to your server, your server talks to Stripe with the secret key, and the secret never enters the bundle. The publishable key stays in the frontend and continues to do the client-side work it is meant for.

If a search ever found sk_live_ or sk_test_ in your public bundle, rotate that key immediately in the Stripe dashboard before doing anything else. Exposure equals compromise: once a secret has been published, you cannot un-publish it, and you have no way to know who already copied it. Rotating issues a new secret key and invalidates the old one, after which you update only the server-side variable. Removing the key from your code without rotating leaves the old, already-public value valid.

  • Secret key: server-only variable, no client prefix, used only from server code.
  • Publishable key: stays in the frontend; that is its job.
  • If it shipped: rotate in the Stripe dashboard first, then redeploy, then re-run the bundle check to confirm only pk_ remains.

Moving the key server-side and rotating it closes this specific exposure. It does not, on its own, secure the server route you just created. A server endpoint that creates charges or refunds still needs its own authentication and authorization, so that a logged-in user cannot trigger Stripe actions on someone else's behalf. That is a separate review from key placement.

Where VibeCodeGuard fits, and what it does not prove

VibeCodeGuard's Launch Check fetches your public JavaScript and looks for secret-shaped strings, including the sk_live_ and sk_test_ patterns. A match means a Stripe secret key is reachable from the public surface, which is one of the clearest launch blockers a scan can surface, and the result comes with severity and the same fix direction: rotate the exposed key at once and move it server-side. It checks the thing an outside visitor can actually download.

What it does not do is review your server code. It cannot confirm that the server route you moved the key behind checks who is calling it, and it does not verify that a charge or refund endpoint enforces the right authorization. That is manual review territory.

Next step: run your production build, search the output for sk_live_, sk_test_, and STRIPE_SECRET, and confirm the only Stripe key in the bundle is a pk_ value. If anything starting sk_ shows up, rotate it in the Stripe dashboard now, then run a focused Launch Check against the public URL before sharing it.

A clean Launch Check means no sk_ string was found in the public bundle it fetched. It does not prove your payment flow is correctly authorized, and it cannot see secrets that live only in private repos, server logs, or build steps it did not observe. Treat a flagged secret key as a launch blocker and rotate immediately; treat a clean result as one signal, not a guarantee.

> launch check

Scan the public surface before launch.

Get severity, evidence, and practical fix guidance for the checks VibeCodeGuard can run from the outside.