DEV Community

ShipRules
ShipRules

Posted 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; 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)