Security · Next.js · · originally published on dev.to

7 Security Bugs AI Agents Write in Next.js + Supabase + Stripe Apps (and the Rules That Stop Them)

AI coding agents are great at getting a Next.js + Supabase + Stripe app working. They're much less reliable at getting it safe. The bugs below all pass a quick click-through test, which is exactly why they ship.

This guide pairs with Ship-Ready AI Rules, and if you want the next step, read Supabase Service Role Key Exposed? How to Check, Rotate and Stop It Happening Again.

Every "fixed" snippet here is taken from a small reference app I built with Next.js 16, @supabase/ssr and the Stripe Node SDK, so you can copy them with some confidence. At the end there's a short rule you can drop into AGENTS.md / .cursor/rules for each one.

1. RLS "enabled" but a policy that protects nothing

Agents love to make an empty query go away by writing this:

-- ❌ passes the "RLS enabled" check, protects nothing
create policy "allow all" on public.subscriptions
  for select using (true);

What you want is ownership scoped to the signed-in user, and no write policy at all for tables only your server should write:

alter table public.subscriptions enable row level security;
create policy "read own subscriptions" on public.subscriptions
  for select to authenticated using ((select auth.uid()) = user_id);

Wrapping auth.uid() in (select ...) lets Postgres evaluate it once per query instead of once per row. Find existing offenders with:

select tablename, policyname from pg_policies where qual = 'true';

Rule: Every table in public has RLS enabled. Never write using (true). If a query returns no rows, fix the policy — never switch keys.

2. "Fixing" an empty result with the service-role key

The second half of bug #1. When RLS correctly returns nothing, agents often swap in the secret/service-role key, which bypasses RLS completely. Keep two clearly named clients and make the dangerous one obvious. (If that key has already reached a client bundle or a repo, see Supabase service role key exposed? How to check, rotate and stop it happening again.)

The pattern:

/** Per-request client acting as the signed-in user. RLS applies. */
export async function createUserClient() { /* createServerClient(url, PUBLISHABLE_KEY, { cookies }) */ }

/** Admin client that BYPASSES RLS. Only for trusted server code such as webhooks. */
export function createAdminClient() {
  const e = env();
  return createClient(e.NEXT_PUBLIC_SUPABASE_URL, e.SUPABASE_SECRET_KEY, {
    auth: { persistSession: false, autoRefreshToken: false },
  });
}

Put import "server-only"; at the top of that file so the build fails if a client component ever imports it.

Rule: The admin client is only used in webhook handlers and background jobs, never in code that runs on behalf of a user.

3. Trusting getSession() on the server

supabase.auth.getSession() reads the cookie without re-validating it with Supabase Auth. On the server, check the user properly before doing anything that matters:

const supabase = await createUserClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });

(getClaims() is also fine if you're on asymmetric JWT signing keys.)

Rule: Server-side authorization uses getUser()/getClaims(), never getSession().

4. Parsing the Stripe webhook body before verifying it

await request.json() re-serialises the payload and the signature check fails. So the agent "fixes" it by deleting the check. Read the raw text instead:

export const runtime = "nodejs";

export async function POST(request: Request) {
  const signature = request.headers.get("stripe-signature");
  if (!signature) return NextResponse.json({ error: "Missing signature" }, { status: 400 });

  // Raw body is required for signature verification. Never parse JSON first.
  const body = await request.text();
  let event: Stripe.Event;
  try {
    event = stripe().webhooks.constructEvent(body, signature, env().STRIPE_WEBHOOK_SECRET);
  } catch {
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }
  // ...
}

Also exclude the webhook path from any auth middleware/proxy so it isn't redirected to a login page:

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico|api/webhooks).*)"],
};

Rule: Webhook handlers read request.text() and verify the signature before touching the payload.

5. Non-idempotent webhooks

Stripe retries deliveries and can send events more than once. (The same rule applies if you bill per use: see how to make usage reporting retry-safe in How to charge for an MCP server with Stripe usage billing.) Record each event id with a primary key and bail on duplicates:

create table public.stripe_events (
  id text primary key,
  type text not null,
  received_at timestamptz not null default now()
);
alter table public.stripe_events enable row level security; -- no policies = no client access
const { error: dupError } = await db.from("stripe_events").insert({ id: event.id, type: event.type });
if (dupError?.code === "23505") return NextResponse.json({ received: true, duplicate: true });
if (dupError) return NextResponse.json({ error: "DB error" }, { status: 500 });

Returning 500 on a real DB error matters too: it tells Stripe to retry later instead of silently losing the event.

Rule: Every webhook is idempotent on event.id; use upsert for state.

6. Granting access from the success redirect

/account?checkout=success is just a URL — anyone can visit it. Access must come from the webhook-mirrored subscription status, and the mapping should be explicit:

export function accessFor(status: string | null | undefined): "active" | "grace" | "none" {
  switch (status) {
    case "active":
    case "trialing":
      return "active";
    case "past_due":
      return "grace";
    default:
      return "none";
  }
}

Link the Stripe customer back to your user at checkout creation (client_reference_id: user.id) so the webhook knows whose subscription it is — never take a user id or price from the request body.

Rule: Paid access is derived only from webhook-written rows. Prices come from env/server config, never from the client.

7. Open redirects in auth callbacks

Magic-link and OAuth callbacks usually accept a ?next= param. Agents pass it straight to redirect(), which turns your login into a phishing redirector (?next=//evil.com). Allow only same-origin relative paths:

export function safeNext(next: string | null | undefined, fallback = "/account"): string {
  if (!next || !next.startsWith("/") || next.startsWith("//") || next.startsWith("/\\")) return fallback;
  return next;
}
const next = safeNext(url.searchParams.get("next"));
return NextResponse.redirect(new URL(ok ? next : "/login?error=link-invalid", url.origin));

Rule: Any redirect target from user input goes through safeNext().

Bonus: fail loudly on bad env

A missing STRIPE_WEBHOOK_SECRET shouldn't become undefined at runtime. Validate lazily (so next build still works without secrets) with zod:

const schema = z.object({
  SUPABASE_SECRET_KEY: z.string().min(1),
  STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
  STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_"),
  STRIPE_PRICE_ID: z.string().startsWith("price_"),
  // ...
});
let cached: z.infer<typeof schema> | undefined;
export function env() { return (cached ??= schema.parse(process.env)); }

The takeaway

None of these are exotic. They're the predictable shortcuts an agent takes when its only goal is "make the error go away." Writing the rules down where the agent reads them every time (AGENTS.md, CLAUDE.md, .cursor/rules) fixes most of them before they're written.


Disclosure: I made these resources. I turned this into a free 20-point Supabase + Stripe pre-launch security checklist. If you'd rather have the full rule files plus the reference app these snippets come from, it's a $19 pack (free sample on GitHub). Want the scan automated? Launch Audit runs 28 checks for these bugs with one /audit command. Either way, I hope the list above saves you a bad launch day — corrections welcome in the comments.