Skip to content
LockShip

Guide · Stripe · Updated

Stripe security for vibe-coded apps: eight checks before you take real money

The day you add a Buy button is a good day. Somebody might actually pay you. It’s also the day the stakes change, because the code your AI builder wrote around Stripe now decides who gets what you sell. Stripe looks after the card. Everything after that, the success page, the webhook, the column that says someone is on Pro, is your app’s job. We sell a $39 certificate and a $9 a month plan through Stripe Checkout ourselves, so this is the list we hold our own code to. It is written for apps built with Lovable, Bolt, v0, Replit, Cursor or Base44, and most of it applies to anything.

New to this? Start with our vibe coding security checklist, eleven checks for any AI-built app. This guide goes deeper on taking money.

The honest short answer

Stripe is very hard to get wrong on its own. If you use Stripe Checkout, card numbers never touch your app. What goes wrong is the thirty or so lines of glue around it, and the mistakes repeat from app to app. The page decides what someone paid. A success page unlocks Pro for anyone who visits it. The webhook believes any request that arrives. A secret key ends up in the JavaScript. A user can flip their own is_pro column. None of these show up when you test the happy path, because the happy path works.

The good news is that each one has a plain check and a short fix, and you can do all eight in an evening. Stripe’s own docs are clear about most of them, so we quote them a lot below. Where we tested something ourselves, we say so.

This happens to real people

In March 2025, a founder who had been sharing on X how he built his SaaS with Cursor posted that he was “under attack”. The things he listed included “maxed out usage on api keys” and “people bypassing the subscription”. He didn’t say how the subscription was bypassed, and we won’t guess. We mention it because it is the honest shape of the risk. Nobody needs to break Stripe. They only need your app to hand out the paid part without asking Stripe first.

How a Stripe payment reaches your app

It helps to know the moving parts, because each check below is about one of them.

  1. Someone clicks Buy. Your server creates a Checkout Session with the price and sends the browser to Stripe’s payment page.
  2. They pay. Stripe sends their browser back to your success_url. That is a redirect, and anyone can type its address.
  3. Stripe also sends a webhook, a signed message from Stripe’s servers straight to yours, saying the session completed. This is the part you can trust, if you check the signature.
  4. Your server records that the person paid, and from then on your app checks that record before it shows anything paid. For a subscription, Stripe keeps sending webhooks as it renews, fails or gets canceled.

The eight checks

Do them in order. The first takes two minutes. Checks 2, 3 and 6 are the ones we would bet on. Run every test against your own app, in Stripe’s test mode where you can, and don’t point these commands at anyone else’s site.

  1. Make sure only the publishable key is in your page
  2. Don’t unlock anything because someone reached your success page
  3. Make your webhook refuse events Stripe didn’t sign
  4. Let your server pick the price, never the browser
  5. Expect the same event twice, and events out of order
  6. Keep the “paid” flag where users can’t write it
  7. Check the subscription on every request, and who opens the portal
  8. Switch from test to live on purpose, all at once

01

Make sure only the publishable key is in your page

Stripe gives you two kinds of key, and only one belongs in the browser. Stripe’s API keys docs say the publishable key (pk_live_ or pk_test_) can identify your account and create tokens or PaymentMethods, but “can’t perform sensitive operations such as creating charges or reading account data.” The same page marks secret keys (sk_) and restricted keys (rk_) as not safe to expose, and says “only publishable keys are safe to expose outside your application’s backend.” The webhook signing secret (whsec_) is a secret too: it is how your server tells a real Stripe event from a fake one.

Stripe’s best practices page spells out what a leaked secret key means: whoever has it can “make unauthorized charges, access customer data, or disrupt your integration.” The usual way it leaks in an AI-built app is a prefix. A variable called NEXT_PUBLIC_STRIPE_SECRET_KEY or VITE_STRIPE_SECRET_KEY is copied into the JavaScript every visitor downloads, whatever its name says. To see what your live site really ships, run this on your own computer:

Terminal
APP=https://your-app.com

# 1. The scripts your homepage loads
curl -s "$APP/" | grep -oE '(src|href)="/[^"]+\.js"' | cut -d'"' -f2 | sort -u \
  | while read -r f; do curl -s "$APP$f"; done > app.js

# 2. Stripe keys that must never be in a page (nothing printed is good)
grep -ohE "(sk|rk)_(live|test)_[A-Za-z0-9]{10,}|whsec_[A-Za-z0-9]{10,}" app.js | sort -u

# 3. Which publishable key the live site uses (fine to be public; should say pk_live_)
grep -ohE "pk_(live|test)_[A-Za-z0-9]{6}" app.js | sort -u

We tried it on a test page with a planted fake sk_live_ key and webhook secret, and it printed both, plus the pk_live_ prefix. On lockship.dev it printed nothing at all: we send buyers to Stripe-hosted Checkout from the server, so our pages don’t need a Stripe key of any kind. Nothing in the second list is what you want. Anything in it is already public. Rotate it in the Stripe Dashboard first, then move it to a server-only variable. Stripe also says that if it detects an exposed secret or restricted key, it notifies you and may deactivate the key itself, so don’t ignore that email if you get one.

A publishable key in your page is fine, but it isn’t nothing. Stripe’s card testing guide notes that “card testers can use your publishable key” to try many stolen cards on your site, and that Checkout and the Payment Element come with rate limits and CAPTCHA built in. That is one more reason to use Stripe’s own payment page rather than a custom card form the AI improvised. The command above only follows scripts on your own domain; our guide to leaked API keys in your frontend bundle has a longer one that follows every file a page loads later, and the order to rotate keys in.

Prompt for your AI builder
Search this whole project, including every file that ends up in the browser, for Stripe keys. The publishable key (pk_live_ or pk_test_) may stay in browser code. Any secret key (sk_), restricted key (rk_) or webhook signing secret (whsec_) must only be read on the server from an environment variable or secret, never from a NEXT_PUBLIC_ or VITE_ variable and never written into the code. Move any you find, list every file you changed, and tell me which keys I now need to rotate in the Stripe Dashboard. Don't change anything else.

02

Don’t unlock anything because someone reached your success page

This is the mistake we would look for first, because it is so natural. The AI makes a /success page, and when it loads, it sets the user to Pro. It works every time you test it. It also works for anyone who types that address without paying. The OWASP testing guide for payment functionality describes exactly this: if the app doesn’t check with the gateway that the payment succeeded, someone can “force-browse to the success.php page” and the order is processed as if they paid. Repeating the request can process it more than once.

Stripe says the same from the other side. Its fulfillment docs put it in a warning box: “You can’t rely on triggering fulfillment only from your checkout landing page, because it’s not guaranteed customers visit that page.” Someone can pay and lose their connection before your page loads, and then they paid and got nothing. And if you sell subscriptions or accept payment methods that settle later, Stripe says webhooks are required.

Here is the test. Sign in as a free test user, then type your own success address with a made-up session ID, something like /success?session_id=cs_test_made_up, or with none at all. If the account is now Pro, or you see a download link, credits, or a thank-you email, the page is unlocking things by itself. A good page either shows a neutral “thanks, give it a moment” or an error, and nothing in your database changes.

The fix Stripe describes is one fulfillment function that both the webhook and the success page call. It asks Stripe for the session, checks payment_status, and gives access to the person your server named when it created the session, using the client_reference_id field Stripe offers for that. It has to be safe to run twice, because Stripe says it “might be called multiple times, possibly concurrently, for the same Checkout Session.”

TypeScript
// lib/billing.ts
import Stripe from "stripe";
import { grantAccess } from "@/lib/db"; // your own database code

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

// Called by the webhook AND by the success page. Safe to run twice.
export async function fulfillCheckout(sessionId: string) {
  const session = await stripe.checkout.sessions.retrieve(sessionId);
  if (session.payment_status === "unpaid") return; // not paid (yet)
  const userId = session.client_reference_id; // set by your server, not the browser
  if (!userId) return;
  // Keyed on the session ID: a second call finds the row and changes nothing.
  await grantAccess({
    userId,
    checkoutSessionId: session.id,
    customerId: typeof session.customer === "string" ? session.customer : null,
    subscriptionId: typeof session.subscription === "string" ? session.subscription : null,
  });
}

On the success page, call fulfillCheckout(session_id) on the server and then show the result. Never on the client, and never by trusting the address. Stripe also says Checkout waits up to 10 seconds for your webhook to answer before it redirects the customer, so most of the time the webhook has already done the work by the time the page loads. We tested this function with a paid session, an unpaid one, a made-up ID and the same session twice: only the paid one granted access, and only once.

Prompt for your AI builder
Find every place this app gives someone a paid feature, credits or an order after a Stripe payment. None of them may run just because the browser reached the success page or has a session_id in the address. Make one server-side function, fulfillCheckout(sessionId), that retrieves the Checkout Session from Stripe, does nothing if payment_status is "unpaid", gives access to the user in client_reference_id (which our server sets when it creates the session), and does nothing the second time it runs for the same session. Call it from the Stripe webhook for checkout.session.completed and checkout.session.async_payment_succeeded, and from the success page on the server. Show me what you changed.

03

Make your webhook refuse events Stripe didn’t sign

Your webhook is a public address that changes your database. Stripe’s webhooks docs say what happens without a check: “an attacker could send fake webhook events to your endpoint to trigger actions like fulfilling orders, granting account access, or modifying records.” Stripe signs every event in a Stripe-Signature header, and its libraries check that signature for you with constructEvent. If verification fails, you get an error and you stop.

There’s one trap the AI walks into a lot, and Stripe puts it in a warning box: “Stripe requires the raw body of the request to perform signature verification.” The signature is over the exact bytes Stripe sent. Stripe’s troubleshooting page lists the usual culprits as frameworks that reorder keys, strip whitespace or convert the body to JSON. We checked it with Stripe’s own Node library: the raw body verified, and the same event read with req.json() and turned back into text failed with “No signatures found matching the expected signature for payload.” That failure tempts people into the worst possible fix, deleting the check because it “doesn’t work”.

  • Next.js App Router (v0, Cursor, most Next apps): read the body with await req.text(). The Next.js route handler docs use exactly that for webhooks and note that, unlike the Pages Router, there is no bodyParser to switch off. Stripe’s App Router example does the same.
  • Next.js Pages Router: set bodyParser: false in that route’s config and read the raw buffer, as the Next.js API routes docs and Stripe’s Pages Router example show.
  • Express (common on Replit): Stripe’s troubleshooting page says app.use(express.json()) must come after the webhook route, or it parses the body first.
  • Supabase Edge Functions (Lovable, Bolt): Supabase’s Stripe webhook example reads req.text(), uses constructEventAsync, and turns off the JWT check for that one function because, in its words, “Stripe verifies the request via its signature.” Turning off the JWT check without the signature check leaves the door open. Bolt’s Stripe integration says it handles signature verification for you; if you wired one up some other way, look.

This is the shape we’d want in a Next.js app. We ran it against signed, unsigned and forged events before putting it here:

TypeScript
// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
import { fulfillCheckout } from "@/lib/billing";
import { claimEvent, releaseEvent, saveSubscription } from "@/lib/db"; // your own database code

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  // The raw body, exactly as Stripe sent it. Not req.json().
  const body = await req.text();
  const signature = req.headers.get("stripe-signature") ?? "";

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }

  // Stripe can deliver the same event twice. Handle each event ID once.
  if (!(await claimEvent(event.id))) return new Response("Already handled");

  try {
    switch (event.type) {
      case "checkout.session.completed":
      case "checkout.session.async_payment_succeeded":
        await fulfillCheckout(event.data.object.id);
        break;
      case "customer.subscription.created":
      case "customer.subscription.updated":
      case "customer.subscription.deleted": {
        // Events can arrive out of order, so ask Stripe for the current state.
        const subscription = await stripe.subscriptions.retrieve(event.data.object.id);
        await saveSubscription(subscription);
        break;
      }
    }
  } catch (error) {
    await releaseEvent(event.id); // so Stripe's retry gets processed
    throw error; // a 500 tells Stripe to try again later
  }
  return new Response("OK");
}

Then test yours from the outside. This sends an event with a made-up signature and a type no handler acts on, so it is harmless even if your check is missing. Use your own webhook address, which is in the Stripe Dashboard under Webhooks:

Terminal
# Sends a fake, unsigned event to your own webhook. Replace the address.
curl -i -X POST https://YOUR-APP.com/api/stripe/webhook \
  -H "Content-Type: application/json" \
  -H "Stripe-Signature: t=1,v1=not-a-real-signature" \
  -d '{"id":"evt_signature_check","object":"event","type":"lockship.signature_check","data":{"object":{}}}'

400 is the good result. A 200 means your endpoint accepts events nobody signed, and the next fake event could say “checkout completed”. While you’re in the Dashboard, check that the endpoint only listens to the events your code handles. Stripe recommends that, and it also recommends rolling the signing secret if you think it leaked.

Prompt for your AI builder
Check our Stripe webhook handler. It must read the raw request body (await req.text() in a Next.js route handler, express.raw() in Express, req.text() in a Supabase Edge Function), verify it with stripe.webhooks.constructEvent (constructEventAsync in Deno and edge runtimes) using the signing secret from an environment variable, and return 400 before doing anything else if that fails. It must not parse the body as JSON first. If it is a Supabase Edge Function, it may skip the JWT check, but only because it checks the Stripe signature instead. Show me the lines that do the check.

04

Let your server pick the price, never the browser

If the browser tells your server what to charge, the browser can lie. OWASP’s payment testing guide calls this price tampering, and its advice is short: “Handle all pricing related information on server-side” and “The only things included in client-side requests should be item IDs and quantities.” Its API Security Top 10 has the same bug in another form, where a request quietly includes a field like total_stay_price that the user should never set.

In a Stripe app it looks like a checkout route that reads amount, price, or a whole price_data object from the request body and passes it to Stripe. Stripe’s prices docs describe price_data as a way to set a custom amount when a session is created, which is useful for things like donations. It is only safe when your server decides the amount. We use it ourselves, with our two prices written in our server code. The fix for everyone else is to create the products and prices in the Stripe Dashboard and let the browser send nothing but a plan name.

To check, open your pricing page with the browser’s developer tools on the Network tab, click Buy, and look at the request your page sends to your own server. A plan name or a Stripe Price ID is fine. A number that looks like your price is the bad result. In test mode, change it and see whether Stripe’s page shows the new amount.

TypeScript
// app/api/checkout/route.ts
import Stripe from "stripe";
import { getSignedInUser } from "@/lib/auth"; // however your app reads the session

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

// The browser picks a plan by name. Only the server knows what it costs.
const PRICES = new Map([
  ["pro_monthly", process.env.STRIPE_PRICE_PRO_MONTHLY!],
  ["pro_yearly", process.env.STRIPE_PRICE_PRO_YEARLY!],
]);

export async function POST(req: Request) {
  const user = await getSignedInUser(req);
  if (!user) return new Response("Sign in first", { status: 401 });

  const { plan } = await req.json();
  const price = PRICES.get(String(plan));
  if (!price) return new Response("Unknown plan", { status: 400 });

  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    line_items: [{ price, quantity: 1 }],
    client_reference_id: user.id, // who gets access once Stripe says it's paid
    customer_email: user.email,
    success_url: `${process.env.APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.APP_URL}/pricing`,
  });
  return Response.json({ url: session.url });
}

Two small details in there are on purpose. The list is a Map because a plain object would answer plan names like constructor with something truthy; with the Map, our tests got a 400 for those. And the session carries the signed-in user’s ID as client_reference_id, which is what check 2 uses to decide who gets access. When we sent a request with an extra price and amount in it, Stripe only ever saw the server’s Price ID.

Prompt for your AI builder
Find where we create Stripe Checkout Sessions, Payment Links or PaymentIntents. The browser may only say which plan or product it wants, and a quantity if we allow that. Pick the price on the server from a fixed list of Stripe Price IDs, or from amounts written in our server code, and ignore any price, amount, currency, discount or trial the request sends. Reject unknown plans with a 400. Show me the list of allowed plans and the code that picks the price.

05

Expect the same event twice, and events out of order

Two facts from Stripe’s webhooks docs catch out code that was only tested once. First, “webhook endpoints might occasionally receive the same event more than once,” and Stripe keeps retrying a failed delivery “for up to three days” in live mode. Second, “Stripe doesn’t guarantee the delivery of events in the order that they’re generated.” A subscription can send an “updated” event after the “deleted” one.

So handle each event ID once. Stripe suggests logging the IDs you’ve processed and skipping ones you’ve seen, and it warns against using the event’s created time to work out order or duplicates. Make fulfillment for one Checkout Session safe to run twice, because Stripe adds that sometimes two separate events arrive for the same thing. And for subscriptions, don’t store the status inside the event you happen to receive last. Ask Stripe for the subscription’s current state and store that. The webhook in check 3 does all three: we sent it the same event twice, two events for one session, and a stale “active” event for a subscription Stripe had canceled. It skipped the repeat, granted access once, and stored “canceled”.

One related habit for when your server creates things in Stripe: send an idempotency key with the request, so that if the connection drops and you retry, Stripe doesn’t create a second customer or charge. Stripe also says to return a 2xx quickly; if your fulfillment is slow, record the event and do the slow part in a background job.

Prompt for your AI builder
Make our Stripe webhook safe against repeats and out-of-order delivery. Store each event.id we handle in a table with a unique constraint and skip events that are already stored. If handling fails, delete the stored ID and return a 500 so Stripe retries. For subscription events, don't trust the status inside the event: retrieve the subscription from Stripe and save its current status. Make fulfillment for one Checkout Session do nothing the second time. Don't change anything else.

06

Keep the “paid” flag where users can’t write it

This is the one we see most in Supabase apps, so it gets the most space. The AI adds an is_pro boolean, a plan, or a credits number to the profiles table. That table almost always has a policy like “users can update their own profile,” so people can change their name. Supabase’s column level security docs explain the catch: a policy like that “gives the post owner full access to update the row, including all of the columns.” Row Level Security decides which rows. It doesn’t decide which columns. So a signed-in user can set their own is_pro to true with the public key and one line in the browser console, and your webhook never knew.

Run this in the Supabase SQL editor, with your own table and column names:

SQL
-- Can signed-in users write is_pro? (true = yes, on any row a policy lets them touch)
select
  has_column_privilege('authenticated', 'public.profiles', 'is_pro', 'UPDATE') as can_update_is_pro,
  has_column_privilege('authenticated', 'public.profiles', 'is_pro', 'INSERT') as can_insert_is_pro;

-- Which policies decide the rows they can insert or update?
select policyname, cmd, qual as which_rows, with_check
from pg_policies
where schemaname = 'public' and tablename = 'profiles' and cmd in ('INSERT', 'UPDATE', 'ALL');

true in either column is the bad result, as long as a policy lets people insert or update their own row. We built the usual AI setup in a local Postgres (PGlite) with Supabase’s default grants: both came back true, a test user set her own is_pro, and a second one signed up already on Pro. A stripe_customer_id in the same table has the same problem, and it matters for check 7.

Supabase doesn’t recommend column privileges for most people. It suggests RLS policies plus “a dedicated table” for things like user roles, and a paid plan is the same kind of thing. Put what someone paid for in its own table that people can read but never write, and let only your webhook write to it with the service role key, on the server:

SQL
create table public.entitlements (
  user_id uuid primary key references auth.users (id) on delete cascade,
  plan text not null,
  status text not null,
  current_period_end timestamptz,
  stripe_customer_id text,
  stripe_subscription_id text,
  updated_at timestamptz not null default now()
);
alter table public.entitlements enable row level security;

-- People can read their own row and nothing else. No insert, update or delete
-- for them at all, so only your server, using the service role key, can write.
revoke all on public.entitlements from anon;
revoke insert, update, delete on public.entitlements from authenticated;
grant select on public.entitlements to authenticated;
create policy "Read own entitlement" on public.entitlements
  for select to authenticated using ((select auth.uid()) = user_id);

In our test, people could read their own row and nobody else’s, every insert or update from a signed-in user was refused, and the server could still write. If you need a quick fix before you move things, take away the table-wide permission and give back only the columns people should edit. Supabase notes that revoking one column does nothing while the table-wide grant is still there, which is why the first line matters:

SQL
-- Take away table-wide INSERT and UPDATE, then give back only the columns people may edit.
revoke insert, update on public.profiles from anon, authenticated;
grant update (display_name) on public.profiles to authenticated;
-- Only if your app creates the profile row from the browser:
grant insert (id, display_name) on public.profiles to authenticated;

After that, both checks returned false, people could still rename themselves, and setting is_pro was refused. List every column your app really lets people edit, or those edits will start failing. Our Supabase RLS checklist has the SQL to review every other table and policy. Not on Supabase? The rule is the same anywhere: a request from a user’s browser must never be able to change what that user paid for.

Prompt for your AI builder
Signed-in users must not be able to give themselves a paid plan. Find where we store whether someone has paid (columns like is_pro, plan, credits, subscription_status or stripe_customer_id). Move them to a separate table, public.entitlements, with RLS on, a select policy so people can read only their own row, and no insert, update or delete for the anon or authenticated roles. Only our Stripe webhook, using the service role key on the server, may write to it. Update the app to read from it. Show me the SQL migration before you run it.

07

Check the subscription on every request, and who opens the portal

A subscription isn’t a one-time unlock. Cards expire, payments fail, people cancel. Stripe’s subscriptions overview lists the statuses, and its subscription webhooks page is direct about the important one: “When a subscription changes to canceled or unpaid, revoke access to your product.” When a subscription goes past_due, Stripe suggests telling the customer and asking them to update their card. Whether they keep access for a few days in the meantime is your call.

The common bug is an app that set is_pro = true once and never looks again. The test is simple in test mode: subscribe with Stripe’s test card, cancel the subscription in the Stripe Dashboard, and see whether the app notices. In our version, every paid feature calls one server function that returns true only for active or trialing, read from the table the webhook keeps up to date. If you’d rather not keep that mapping yourself, Stripe’s Entitlements feature tracks which features each customer has and tells you when to grant or remove them.

Lovable does this differently, and it’s worth knowing. Its Stripe docs say the subscription check runs when users sign in and use your app, asking Stripe directly whether they have an active subscription, and that subscriptions are matched to users by email address. That avoids trusting a redirect. It also means the email someone signs in with decides whose subscription they get, so make sure your app confirms email addresses before it trusts them.

Then the customer portal, where people cancel and change cards. Stripe’s portal docs say: “Make sure to authenticate customers on your site before creating sessions for them.” The bug to look for is a portal route that takes the Stripe customer ID from the request, so anyone who learns someone’s cus_ ID can open their billing page. Take the ID for the signed-in user from your own database, from a column only your webhook writes. In our test, a second user who put the first user’s customer ID in the request got a 404.

Prompt for your AI builder
Every paid feature must check the user's plan on the server, on every request, from the entitlements table our Stripe webhook keeps up to date. Treat only "active" and "trialing" as paid. Handle customer.subscription.updated and customer.subscription.deleted so canceled, unpaid, incomplete_expired and paused subscriptions lose access. The customer portal route must require a signed-in user and take the Stripe customer ID for that user from our database, never from the request. List each place that checks.

08

Switch from test to live on purpose, all at once

Build everything in test mode, where nobody pays real money. Stripe’s keys docs say each mode “has its own set of API keys, and objects in one mode aren’t accessible to the other,” and its sandboxes page describes the isolated test environments it now recommends for new integrations. The trouble is at the switch. Everything Stripe-related changes together when you go live, and missing one piece fails in confusing ways:

  • The keys: pk_test_ and sk_test_ become pk_live_ and sk_live_ (or a live restricted key).
  • The Price IDs. Lovable’s Stripe docs say it plainly: “Price IDs differ between test and live mode.” A test Price ID in production makes every checkout fail.
  • The webhook signing secret. Stripe says that if the same endpoint is used for test and live, “the secret is different for each one.”
  • Retries. Stripe retries a failed live delivery for up to three days, but a sandbox delivery only three times over a few hours, so a bug you never saw in testing can show up as a pile of retries later.

Two traps run the other way. A live site still on a pk_test_ key takes no real money at all; the last command in check 1 shows which one your site uses. And Lovable warns that its preview has no separate test environment when you bring your own Stripe key: “with a live key, payments made from the preview are real.” Keep live keys in production only, and keep the secret ones on the server.

Prompt for your AI builder
List every Stripe setting this app uses and where it comes from: the publishable key, the secret or restricted key, the webhook signing secret, and every Price ID. Make all of them environment variables or secrets, with no Stripe IDs written in the code. Tell me which values are test mode (pk_test_, sk_test_, rk_test_, test Price IDs) and which are live, so I can set the live ones in production only.

What a clean result means

If you got through all eight, you’ve done more than most apps that take money, and that is worth feeling good about. It means the obvious ways to get Pro for free are closed today. It doesn’t mean they stay closed. The next feature the AI adds might unlock itself on a page load again, or put credits back on the profiles table. Run checks 2, 3 and 6 again whenever you change anything about payments, and do one real purchase on the live site after every change, with a real card, then refund it.

Where LockShip fits

We built LockShip to do the outside-in part of this kind of list for you. You paste your app’s address and prove you own it with a meta tag, a file at /.well-known/lockship.txt, or a DNS record. Then the scan reads your app the way a stranger would. It downloads your homepage and a few pages linked from it, follows the scripts they load, reads any public source maps, and searches all of it for Stripe secret keys, restricted keys and webhook signing secrets, live and test, along with the other secret keys it knows about. It doesn’t flag a publishable key, because that one is meant to be public. If your pages use Supabase, it checks which tables the public key can read, which would include a subscriptions or payments table left open.

The rest of this guide is out of its reach, and we want to be plain about that. The scan only sends GET and HEAD requests and skips links that look like checkout, purchase, buy, pay or billing. It never opens a checkout, never pays, and never sends anything to your webhook, so it can’t tell whether your webhook checks signatures, whether your success page unlocks things on its own, or what your server charges. It never signs in, so it can’t see whether a signed-in user can write their own is_pro, whether canceled subscriptions lose access, or who can open your billing portal. It doesn’t tell you whether your live site still uses a pk_test_ key either; the command in check 1 does. Checks 2 to 8 are yours to do. A clean scan means “nothing obvious from the outside right now,” not “your payments are safe.”

The summary is free: a score and the single worst thing we found. The $39 Launch Safe certificate includes every finding with a fix prompt you can paste into your AI builder, a PDF, and seven days of re-scans while you fix things. A passing re-scan gives you a badge you can put on your site.

Want to know what a stranger can see before you take real money?

Paste your app’s address, prove you own it, and see what your public pages give away. The summary is free.

Run a free scan

Before you rely on a result, read the disclaimer.

Questions people ask

Is it safe to put my Stripe publishable key in my frontend?

Yes. Stripe says publishable keys, the ones starting pk_live_ or pk_test_, are safe to include in front-end code. They can identify your account and create tokens or PaymentMethods, but can’t create charges or read account data. Secret keys (sk_), restricted keys (rk_) and webhook signing secrets (whsec_) must never be in anything a browser downloads. If one was, rotate it.

Can I unlock paid features on the Stripe success_url?

Not on the redirect alone. Stripe’s fulfillment docs say you can’t rely on the landing page, because customers aren’t guaranteed to reach it, and anyone can type the address of a success page. Use the session_id in that address only to ask Stripe, from your server, whether the session was paid, and use a webhook so every payment gets fulfilled even when the customer never comes back.

Why does Stripe webhook signature verification fail in Next.js?

Almost always because the body was parsed before verification. In the App Router, pass await req.text() to constructEvent, not the result of req.json(). In the Pages Router, turn off bodyParser for that route and read the raw buffer. In Express, put express.json() after the webhook route. The other usual cause is the wrong signing secret: the Stripe CLI, the Dashboard endpoint, and test and live mode each have their own.

Do I need webhooks if Lovable or Bolt set up Stripe for me?

Bolt says its Stripe integration registers the webhook and verifies signatures for you. Lovable’s own-Stripe integration doesn’t use webhooks by default; your app asks Stripe directly whether a user has an active subscription. If you need something to happen for every payment, like sending an order or adding credits, Stripe says webhooks are required, and Lovable can write that function. Either way, checks 4 and 6 are still yours to do.

Can LockShip check my Stripe integration?

Only the part a stranger can see. A LockShip scan looks for Stripe secret keys, restricted keys and webhook signing secrets in the JavaScript your site serves. It never opens a checkout, never pays, never sends anything to your webhook and never signs in, so it can’t tell whether your success page, webhook, prices or paid flag are safe. Checks 2 to 8 are on you.

Keep reading

Sources

We read each of these on 19 October 2026. Stripe’s docs change, so if something here disagrees with them, trust Stripe and tell us at hi@lockship.dev. The code and SQL above ran in our own test setup with Stripe’s Node library and a local Postgres; adapt the names to your app and test in Stripe’s test mode first. We didn’t run any of it against anyone else’s app.