AI coding agents are great at making things work. They're much worse at making things safe. After reviewing a pile of Cursor and Claude Code generated Next.js + Supabase + Stripe apps, the same two bugs come up again and again. They pass every manual test, and both can cost you real money.
Here they are, how to spot them, and how to stop your agent writing them again.
Mistake 1: the Stripe webhook that never verifies anything
Ask an agent for a webhook route and you'll often get this:
// app/api/stripe/webhook/route.ts ❌
export async function POST(req: Request) {
const event = await req.json();
if (event.type === "checkout.session.completed") {
await markPaid(event.data.object.customer);
}
return Response.json({ received: true });
}
It works in testing. It also means anyone who can find your URL can POST a fake checkout.session.completed and unlock paid features for free.
The agent sometimes "fixes" it by adding stripe.webhooks.constructEvent, but after calling req.json(). Signature verification needs the raw body, byte for byte. Once it's parsed and re-stringified, the signature never matches. So the agent "solves" that by wrapping the check in a try/catch that logs and carries on. Now you have code that looks verified and isn't.
The correct version:
// ✅
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text(); // raw body
const sig = req.headers.get("stripe-signature");
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig!, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response("Invalid signature", { status: 400 }); // fail closed
}
// idempotency: Stripe retries, so the same event can arrive twice
const firstTime = await recordEventOnce(event.id);
if (!firstTime) return Response.json({ received: true });
if (event.type === "checkout.session.completed") {
await markPaid((event.data.object as Stripe.Checkout.Session).customer as string);
}
return Response.json({ received: true });
}
Three rules to remember:
-
req.text(), neverreq.json(), beforeconstructEvent. - Fail closed. A bad signature returns 400. Never log it and continue.
-
Idempotency on
event.id. Stripe retries, and your "grant credits" handler shouldn't run twice.
Mistake 2: Supabase tables with no Row Level Security
The second pattern is quieter. The agent writes a migration:
create table profiles (
id uuid primary key references auth.users,
plan text default 'free',
stripe_customer_id text
);
No enable row level security. Your anon key ships in every browser bundle, so this table is now readable and writable by anyone through the auto-generated API. Any user can run update profiles set plan = 'pro' from the dev console.
When the agent does hit an RLS error, its favourite "fix" is worse: it imports SUPABASE_SERVICE_ROLE_KEY into a client component, or puts it in a NEXT_PUBLIC_ variable. That key bypasses RLS completely, and now it's public.
The correct version:
alter table profiles enable row level security;
create policy "read own profile" on profiles
for select using ( (select auth.uid()) = id );
-- note: no update policy for 'plan'; only the server (webhook) changes plans
And the rules:
- Every new table gets
enable row level securityin the same migration. -
service_roleonly in server-only code (route handlers, server actions, edge functions), never in a file with"use client"and never behindNEXT_PUBLIC_. -
Billing columns are written by the webhook only. Users never get an update policy on
plan.
Why the agent keeps doing this
None of this is the model being dumb. It's optimizing for "the error went away." A failing signature check and an RLS denial both look like bugs to remove. Unless the rules say otherwise, removing the guardrail is the shortest path to green.
So give it rules. Cursor reads .cursor/rules/*.mdc, Claude Code reads CLAUDE.md, and Codex/Copilot read AGENTS.md. A rule scoped to the right files works far better than a giant prompt:
---
description: "Stripe webhook safety"
globs: ["**/webhook*/**", "**/*stripe*"]
alwaysApply: false
---
- Read the raw body with req.text() and verify with stripe.webhooks.constructEvent BEFORE parsing.
- On signature failure return 400. Never catch-and-continue.
- Deduplicate on event.id before side effects.
Then back the rules with a check so you're not just trusting the agent. A grep in CI for req.json() in webhook routes, SERVICE_ROLE in client files, and create table without a matching enable row level security catches most regressions in a few milliseconds.
Free rules you can drop in
I packaged these rules (plus Godot 4, Unity 6 and discord.js v14 versions) in .mdc, AGENTS.md and CLAUDE.md formats. MIT, copy them into your project:
👉 https://github.com/otctrading101/shiprules-free
If you want the expanded versions with the Node scanner, Claude Code hooks that block these patterns before they're written, and a GitHub Actions workflow, those are on Gumroad (disclosure: I make them).
What other "make the error go away" fixes have you caught your agent making? Drop them in the comments and I'll turn the common ones into rules.
Disclosure: This post was written with AI assistance. I make ShipRules (paid packs linked above); the free GitHub repo is MIT.
Top comments (10)
the try/catch around constructEvent that logs and continues is the scary version, because the route still returns 200 and looks "verified" in a code review. do you usually catch that in review by insisting on a 400 on verify failure, or by adding a test that posts a forged body and asserts reject?
Both, but the test is the one that holds. "Verify failure must return 400" is a review rule that gets skipped the first time someone is in a hurry. A test that posts a forged body and asserts a 400 fails CI every time someone regresses it. I'd add a second case too: a valid signature with a tampered body. And in review, the shape to grep for is constructEvent inside a try whose catch doesn't return. That's exactly what agents tend to write.
a test that fails ci every time beats a review rule people skip when they're rushing, agreed. the valid signature plus tampered body case is a good second one since it proves the check runs on the raw body. do you write the same kind of test for the rls side too, like a request carrying someone else's user id?
Yes, same shape. Sign in as user A, then hit the table through the API with user B's row id and expect zero rows back (or a 403) for select, update and delete. Run it with the anon key and a real user session, not the service role key, since the service role skips RLS and the test passes for the wrong reason. The other one worth having is an insert that sets user_id to someone else's id, which catches a policy that has USING but no WITH CHECK.
the service role key making the test pass for the wrong reason is such an easy one to miss, and it's green, so nobody looks twice. USING without WITH CHECK is the same kind of thing, looks fine in review and only shows up on an insert. the insert-with-someone-else's-user_id case is a good one to have, thanks.
Yeah, the green ones are the dangerous ones. One more thing that helps: run those RLS tests in CI against a local Supabase (supabase start, apply migrations, then the tests), so a migration that quietly drops or loosens a policy fails the build instead of shipping.
running them against a local supabase in ci is the part that makes it stick. a migration that loosens a policy is exactly the kind of diff nobody reads closely, so having the build fail on it is way better than hoping someone spots it. this thread turned into a pretty good checklist, thanks.
Glad it helped, and thanks for pushing on it. You're right that policy diffs are the ones that slide through review, so letting CI catch them is the only version I trust. Appreciate you sticking with the thread.
The raw body point for Stripe signatures is the one I see agents get wrong most often. Pairing that with an idempotency table on event.id stops the retry storm from double granting. On the Supabase side, the other pattern that shows up next to open RLS is granting trial credits in the signup handler instead of in the checkout.session.completed path, so a forged or skipped webhook is not even needed. Moving the credit grant behind the signed event closes that hole without changing the rest of the agent written flow.
Agreed on all of this. The trial-credits one is nasty because your RLS can be perfect and you still give the product away. The grant has to live only behind the signed event. One addition on idempotency: insert event.id into a table with a unique constraint before doing the work, not after. Otherwise two concurrent retries can both pass the "already processed?" check and both grant.