DEV Community

Shah
Shah

Posted on

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.

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

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:

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

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 });
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().

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 });
  }
  // ...
}
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.

5. Non-idempotent webhooks

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.

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";
  }
}
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.

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;
}
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().

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)); }
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.

Top comments (2)

Collapse
 
cekuu35 profile image
Cenk KURTOĞLU •

The select tablename, policyname from pg_policies where qual = 'true' query is the right first sweep, but the bug class just past it is the one that ships. Agents rarely leave a bare USING (true) once they've been told not to - they write a policy that mentions the user but doesn't scope to the row:

-- passes both "RLS enabled" and "there's a policy, it mentions auth"
create policy "Users can view profiles"
  on public.profiles for select
  using (auth.uid() is not null);
Enter fullscreen mode Exit fullscreen mode

qual = 'true' misses this (the expression isn't literally true), a linter is satisfied, and every signed-in user reads every row - emails, stripe_customer_id, the lot. Catching it needs the roles, not just the qual:

select tablename, policyname, cmd, qual, with_check
from pg_policies
where schemaname = 'public'
  and (qual is null or qual !~ ('uid\(\)\s*=\s*|=\s*.*uid\(\)'));
Enter fullscreen mode Exit fullscreen mode

Empty result is the baseline you want. I hit exactly this shape in three live apps last week (the "does this email exist" login helper is the recurring offender). Put the same red/green test next to your migration so CI catches it: a runnable minimal demo - npm ci && npm test, red on broken, green on fixed, no Docker - is here: github.com/cekuu35/supabase-rls-le...

Collapse
 
elijahbrown profile image
Elijah Brown •

One more parallel to #6 on the free-tier side: treating "account just created" as proof they should get credits. Anyone can open another account with Gmail dots or a plus tag and look brand new. Key the credit grant on a normalised email in a separate trial_claims table with a unique index, and only grant when that insert lands. Same idea as deriving paid access from webhook-written rows rather than the success URL.