Sieh haeufige Sicherheitsluecken, die AI-Coding-Agents in Next.js-Apps produzieren
Agenten bringen eine Next.js-App mit Supabase und Stripe zwar schnell zum Laufen, uebersehen dabei aber typische Sicherheitsfehler, die einfache Funktionstests nicht aufdecken. Ein Regelkatalog soll genau diese Fehlerklassen abfangen.

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.
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.
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 ); Enter fullscreen mode Exit fullscreen mode 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 ); Enter fullscreen mode Exit fullscreen mode 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'; Enter fullscreen mode Exit fullscreen mode Rule: Every table in public has RLS enabled. Never write using (true). If a query returns no rows, fix the policy — never switch keys.
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:
Rule: The admin client is only used in webhook handlers and background jobs, never in code that runs on behalf of a user.
const supabase = await createUserClient (); const { data: { user } } = await supabase. auth. getUser (); if (! user ) return NextResponse. json ({ error: " Not signed in " }, { status: 401 }); Enter fullscreen mode Exit fullscreen mode ( getClaims() is also fine if you're on asymmetric JWT signing keys.)
Rule: Server-side authorization uses getUser() / getClaims(), never getSession().
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 }); } //... } Enter fullscreen mode Exit fullscreen mode 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).*) " ], }; Enter fullscreen mode Exit fullscreen mode Rule: Webhook handlers read request.text() and verify the signature before touching the payload.
Stripe retries deliveries and can send events more than once. 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 Enter fullscreen mode Exit fullscreen mode 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 }); Enter fullscreen mode Exit fullscreen mode 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.
/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 "; } } Enter fullscreen mode Exit fullscreen mode 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.
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; } Enter fullscreen mode Exit fullscreen mode const next = safeNext ( url. searchParams. get ( " next " )); return NextResponse. redirect ( new URL ( ok? next: " /login?error=link-invalid ", url. origin )); Enter fullscreen mode Exit fullscreen mode Rule: Any redirect target from user input goes through safeNext().
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 )); } Enter fullscreen mode Exit fullscreen mode 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 30-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 $15 pack (25% off with DEVTO25). Either way, I hope the list above saves you a bad launch day — corrections welcome in the comments.