August 16, 2026

Exposed sk_live_ Stripe key in the JS: how it happens with Bolt, Lovable, or v0

It's the most severe vulnerability we detect, and statistically one of the most common on apps generated with Bolt, Lovable, v0 or Cursor: a Stripe secret key, sk_live_, that ends up in the JavaScript sent to the browser. Here's how it happens, how to find it yourself, and how to avoid it.

Two Stripe keys, two very different jobs

Stripe always issues a pair of keys per environment (test and live):

  • pk_live_... (publishable key) — designed to be exposed client-side. It initializes Stripe.js and Stripe Elements in the browser.
  • sk_live_... (secret key) — grants full access to the Stripe API: create charges, trigger refunds, list every customer and their payment methods, change account settings. It must never leave your server.

The problem: both start with sk_ or pk_, and an AI assistant generating a payment component in a single pass doesn't always make the distinction — especially when asked to "add Stripe" without specifying the client/server split.

How the secret key ends up client-side

Three scenarios come up again and again:

  • A React component calls new Stripe(process.env.STRIPE_SECRET_KEY) directly in a file that runs in the browser (not an API route). The bundler ships it as-is in the public JS.
  • The environment variable is prefixed NEXT_PUBLIC_ (or VITE_) by mistake — this prefix explicitly tells the bundler "include this in the client bundle", and it obeys without distinguishing a public key from a secret one.
  • The key is hardcoded ("just to test") in a .tsx file during prototyping, and never removed before deployment.

In all three cases, the result is the same: open DevTools, the Network or Sources tab, and the key is right there, in plain text, in a JS file downloaded by any visitor.

How to spot it yourself in 30 seconds

No tool needed: open your site, press Ctrl+U (or Cmd+Option+U) to view source, then in DevTools go to the Network tab, filter on JS, and Ctrl+F through every loaded file looking for sk_live_ or sk_test_. That's exactly what Vetora automates with this regex over the HTML and every loaded script:

const stripeLiveKeyRegex = /sk_live_[A-Za-z0-9]{20,}/;
const stripeTestKeyRegex = /sk_test_[A-Za-z0-9]{20,}/;

An exposed sk_test_ key is classified high rather than critical — it only grants access to the test environment — but it's still worth fixing: it usually signals the same architectural mistake exists in production too, with sk_live_ this time.

The fix

Every operation using the secret key must go through the server — an API route, an edge function, a webhook. The client never talks to the Stripe API directly with sk_, it calls your own backend, which then calls Stripe:

// app/api/create-checkout/route.ts (server-side only)
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const session = await stripe.checkout.sessions.create({ /* ... */ });
  return Response.json({ url: session.url });
}

Client-side, only the publishable key appears, and that's expected — it's built for that:

// client component
import { loadStripe } from '@stripe/stripe-js';
const stripe = await loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);

If you find an sk_live_ key already exposed: roll it immediately in the Stripe dashboard (Developers → API keys → Roll key). A key that was public, even for a few hours, should be considered compromised.

Using Stripe on an app built with Bolt, Lovable, v0 or Cursor? Scan your app to check no secret key is lurking in your JavaScript.