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; code LAUNCH30 takes 30% off through Oct 22).
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 (0)