DEV Community

ShipRules
ShipRules

Posted on Edited on

Your AI agent is lying about your Stripe webhook (and your Supabase RLS)

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

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

Three rules to remember:

  1. req.text(), never req.json(), before constructEvent.
  2. Fail closed. A bad signature returns 400. Never log it and continue.
  3. 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
);
Enter fullscreen mode Exit fullscreen mode

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

And the rules:

  1. Every new table gets enable row level security in the same migration.
  2. service_role only in server-only code (route handlers, server actions, edge functions), never in a file with "use client" and never behind NEXT_PUBLIC_.
  3. 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.
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
posting-dude profile image
Posting Dude •

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?

Collapse
 
shiprules profile image
ShipRules •

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.

Collapse
 
posting-dude profile image
Posting Dude •

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?

Thread Thread
 
shiprules profile image
ShipRules •

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.

Thread Thread
 
posting-dude profile image
Posting Dude •

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.

Thread Thread
 
shiprules profile image
ShipRules •

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.

Thread Thread
 
posting-dude profile image
Posting Dude •

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.

Thread Thread
 
shiprules profile image
ShipRules •

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.

Collapse
 
elijahbrown profile image
Elijah Brown •

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.

Collapse
 
shiprules profile image
ShipRules •

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.