If you scaffolded a SaaS with Cursor, Bolt, Lovable, or Windsurf in the last year, you probably shipped faster than any tutorial ever let you. You also probably shipped the same three holes that show up in almost every AI-generated fullstack app. I have reviewed a pile of these now, and the failures are boringly consistent.
Here are the three, with the exact checks that catch each one. No theory, just the thing to run before you take a payment.
1. Supabase called straight from the client, with RLS off
The generated code loves this pattern:
const { data } = await supabase.from("orders").select("*");
Run that in a client component and it works perfectly, because it is talking to Postgres with the anon key. If Row-Level Security is not enabled on that table, "select all" returns everyone's rows. It compiles, it renders, it leaks.
The check:
select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
and rowsecurity = false;
Every row that comes back is a table any anon key can read. Turn RLS on, then add a policy per table:
alter table orders enable row level security;
create policy "owners read own orders"
on orders for select
using (auth.uid() = user_id);
The trap is that RLS off looks identical to RLS on from the app's point of view. Nothing breaks. That is why it ships.
2. A Stripe webhook with no signature verification
The default AI output is often:
export async function POST(req: Request) {
const event = await req.json();
if (event.type === "checkout.session.completed") { /* grant access */ }
}
That endpoint will happily provision a paid plan for anyone who POSTs the right JSON. No Stripe account required.
The check: does your handler call stripe.webhooks.constructEvent against the raw body with the signing secret? If it parses JSON first, it cannot verify the signature, because the raw bytes are already gone.
const sig = req.headers.get("stripe-signature")!;
const raw = await req.text(); // raw body, not req.json()
const event = stripe.webhooks.constructEvent(raw, sig, process.env.STRIPE_WEBHOOK_SECRET!);
3. The same webhook, fired twice
Stripe retries. Networks duplicate. If your handler is not idempotent, one payment can provision a plan twice, send two welcome emails, or double-credit an account. The fix is a unique constraint on the event id:
create table stripe_processed_events (
event_id text primary key,
processed_at timestamptz default now()
);
Insert first, and if the insert conflicts, return 200 { duplicate: true } and do nothing else. That one table kills the whole class of bug.
Why these keep shipping
Every one of these is invisible in local development and invisible to the AI that wrote it, because the code "works". It returns data, it renders, it accepts a request. The failure only shows up under an attacker or a retry, long after launch.
The pattern is always the same: the generator optimizes for the happy path you can see, and skips the boundary you cannot.
What I did about it
I got tired of re-fixing the same three holes, so I packaged the hardened foundation I keep rebuilding: a Next.js 15 App Router starter with strict Supabase RLS migrations, a signature-verified idempotent Stripe webhook, a Zod env schema that fails at boot instead of in production, and the Cursor/Windsurf rules that stop the generator from reintroducing the anti-patterns. It is the version I wish the tools shipped by default.
If that saves you a weekend, it is here, $14.99, instant download, and code VIBE30 takes 30% off:
https://ancuboy.gumroad.com/l/cursor-bolt-production-hardening-pack
But you do not need it to fix the three above. Run the RLS query first. That is free and it is the one that actually leaks data.
Top comments (0)