<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[ShipRules]]></title><description><![CDATA[ShipRules]]></description><link>https://shiprules.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Thu, 22 Oct 2026 04:14:31 GMT</lastBuildDate><atom:link href="https://shiprules.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Your AI agent is lying about your Stripe webhook (and your Supabase RLS)]]></title><description><![CDATA[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…]]></description><link>https://shiprules.hashnode.dev/your-ai-agent-is-lying-about-your-stripe-webhook-and-your-supabase-rls</link><guid isPermaLink="true">https://shiprules.hashnode.dev/your-ai-agent-is-lying-about-your-stripe-webhook-and-your-supabase-rls</guid><category><![CDATA[AI]]></category><category><![CDATA[Next.js]]></category><category><![CDATA[supabase]]></category><category><![CDATA[stripe]]></category><category><![CDATA[Security]]></category><dc:creator><![CDATA[ShipRules]]></dc:creator><pubDate>Sun, 18 Oct 2026 14:33:59 GMT</pubDate><content:encoded><![CDATA[<p>AI coding agents are great at making things <em>work</em>. They're much worse at making things <em>safe</em>. 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.</p>
<p>Here they are, how to spot them, and how to stop your agent writing them again.</p>
<h2>Mistake 1: the Stripe webhook that never verifies anything</h2>
<p>Ask an agent for a webhook route and you'll often get this:</p>
<pre><code class="language-ts">// 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 });
}
</code></pre>
<p>It works in testing. It also means <strong>anyone who can find your URL can POST a fake <code>checkout.session.completed</code></strong> and unlock paid features for free.</p>
<p>The agent sometimes "fixes" it by adding <code>stripe.webhooks.constructEvent</code>, but after calling <code>req.json()</code>. Signature verification needs the <strong>raw</strong> 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 <em>looks</em> verified and isn't.</p>
<p>The correct version:</p>
<pre><code class="language-ts">// ✅
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 });
}
</code></pre>
<p>Three rules to remember:</p>
<ol>
<li><strong><code>req.text()</code>, never <code>req.json()</code>,</strong> before <code>constructEvent</code>.</li>
<li><strong>Fail closed.</strong> A bad signature returns 400. Never log it and continue.</li>
<li><strong>Idempotency on <code>event.id</code>.</strong> Stripe retries, and your "grant credits" handler shouldn't run twice.</li>
</ol>
<h2>Mistake 2: Supabase tables with no Row Level Security</h2>
<p>The second pattern is quieter. The agent writes a migration:</p>
<pre><code class="language-sql">create table profiles (
  id uuid primary key references auth.users,
  plan text default 'free',
  stripe_customer_id text
);
</code></pre>
<p>No <code>enable row level security</code>. Your anon key ships in every browser bundle, so this table is now <strong>readable and writable by anyone</strong> through the auto-generated API. Any user can run <code>update profiles set plan = 'pro'</code> from the dev console.</p>
<p>When the agent <em>does</em> hit an RLS error, its favourite "fix" is worse: it imports <code>SUPABASE_SERVICE_ROLE_KEY</code> into a client component, or puts it in a <code>NEXT_PUBLIC_</code> variable. That key bypasses RLS completely, and now it's public.</p>
<p>The correct version:</p>
<pre><code class="language-sql">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
</code></pre>
<p>And the rules:</p>
<ol>
<li><strong>Every new table gets <code>enable row level security</code> in the same migration.</strong></li>
<li><strong><code>service_role</code> only in server-only code</strong> (route handlers, server actions, edge functions), never in a file with <code>"use client"</code> and never behind <code>NEXT_PUBLIC_</code>.</li>
<li><strong>Billing columns are written by the webhook only.</strong> Users never get an update policy on <code>plan</code>.</li>
</ol>
<h2>Why the agent keeps doing this</h2>
<p>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.</p>
<p>So give it rules. Cursor reads <code>.cursor/rules/*.mdc</code>, Claude Code reads <code>CLAUDE.md</code>, and Codex/Copilot read <code>AGENTS.md</code>. A rule scoped to the right files works far better than a giant prompt:</p>
<pre><code class="language-md">---
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.
</code></pre>
<p>Then <strong>back the rules with a check</strong> so you're not just trusting the agent. A grep in CI for <code>req.json()</code> in webhook routes, <code>SERVICE_ROLE</code> in client files, and <code>create table</code> without a matching <code>enable row level security</code> catches most regressions in a few milliseconds.</p>
<h2>Free rules you can drop in</h2>
<p>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:</p>
<p>👉 <strong><a href="https://github.com/otctrading101/shiprules-free">https://github.com/otctrading101/shiprules-free</a></strong></p>
<p>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 <a href="https://shiprules.gumroad.com/l/lztkjm">Gumroad</a> (disclosure: I make them; code <strong>LAUNCH30</strong> takes 30% off through Oct 22).</p>
<p>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.</p>
<hr />
<p><em>Disclosure: This post was written with AI assistance. I make ShipRules (paid packs linked above); the free GitHub repo is MIT.</em></p>
<p><em>Originally published on <a href="https://dev.to/shiprules/your-ai-agent-is-lying-about-your-stripe-webhook-and-your-supabase-rls-22e7">DEV</a>.</em></p>
]]></content:encoded></item></channel></rss>