DEV Community

Ryan Cole
Ryan Cole

Posted on

Your AI-coded app is one Supabase query away from a data leak (3 checks that catch it)

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("*");
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 */ }
}
Enter fullscreen mode Exit fullscreen mode

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!);
Enter fullscreen mode Exit fullscreen mode

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()
);
Enter fullscreen mode Exit fullscreen mode

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)