7 Security Holes AI Agents Keep Shipping: Next.js + Supabase + Stripe

AI agents often take shortcuts in Next.js+Supabase+Stripe apps, creating 7 classes of security holes that pass a quick click-through test.
The article lists 7 security vulnerabilities AI coding agents most often introduce when building Next.js + Supabase + Stripe apps: RLS policies written as `using (true)`, using the service-role key to bypass RLS, trusting `getSession()` on the server, parsing JSON before verifying Stripe webhook signatures, non-idempotent webhooks, granting access from a URL success redirect, open redirects in auth callbacks, and silent failures on missing env vars. Each comes with copy-pasteable fix code and a clear rule — for example, use `getUser()` for server authorization, webhooks must read `request.text()` and verify signatures, and paid access must be determined only by webhook-written DB state. The author recommends putting these rules in AGENTS.md, CLAUDE.md, or .cursor/rules so agents avoid most issues before writing code.
AI coding agents are great at getting a Next.js + Supabase + Stripe app to run, but far less great at making it secure. All of the following vulnerabilities pass a quick click-through test — which is exactly why they ship.

1. RLS Is Enabled, but the Policies Are Toothless
Agents often write create policy "allow all" on public.subscriptions for select using (true); to make empty queries go away. The right approach is to scope ownership to the signed-in user, and server-write-only tables should have no write policy at all.
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. To find existing problems:
select tablename, policyname from pg_policies where qual = 'true';
Rule: Enable RLS on every table under public. Never write using (true). If a query returns nothing, fix the policy — never swap keys.
What is RLS: Row Level Security is a native PostgreSQL feature that lets you define policies at the table level, controlling which rows are visible or writable to which role and for which operations. In Supabase, the frontend connects to the database directly using the anon key, so RLS is the only data isolation barrier. Without RLS, or with a policy written as using (true), anyone holding the anon key can read the entire table — including other people's subscriptions, orders, DMs, and so on.
Auth roles: Supabase has built-in database roles anon (not signed in) and authenticated (signed in), and auth.uid() returns the user id from the current JWT. to authenticated in a policy means it only applies to signed-in users.
2. "Fixing" Empty Results with the service-role Key
When RLS correctly returns nothing, agents often switch to the secret/service-role key, which bypasses RLS entirely. Keep two clearly named clients so the dangerous one is obvious:
/** Per-request client that acts as the signed-in user. RLS applies. */
export async function createUserClient() { /* ... */ }
/** Admin client that bypasses RLS. Only for trusted server code like webhooks. */
export function createAdminClient() { /* ... */ }
Add import "server-only"; at the top of that file — the build fails if a client component imports it.
Rule: The admin client is only for webhook handlers and background jobs, never for code acting on behalf of a user.
service-role key vs. anon key: A Supabase project provides two kinds of API keys. The anon key is public, subject to RLS, and can only act as the signed-in user; the service-role key has database superuser privileges, bypasses RLS entirely, and must never appear in the browser or a client component. Once the service-role key is accidentally bundled into the frontend, an attacker can read and write the entire database.
The server-only package: This is an official Next.js npm package. Once imported, it causes an immediate build failure if pulled in by a client component ('use client'), preventing sensitive server code from leaking to the browser at compile time.
3. Trusting getSession() on the Server
supabase.auth.getSession() reads the cookie without re-verifying with Supabase Auth. Before doing anything important on the server, check the user properly:
const supabase = await createUserClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
getClaims() also works if you use an asymmetric JWT signing key.
Rule: Use getUser()/getClaims() for server-side authorization, never getSession().
The essential difference between getSession() and getUser(): getSession() just reads the JWT from the cookie and decodes it, without verifying the signature or expiry against the Supabase Auth server — so the cookie can be forged. getUser() calls Supabase Auth's /user endpoint with that JWT, the server verifies the token is genuine, and then returns the user info. Before making any authorization decision on the server (such as checking admin status, charging money, or writing to the database), you must use getUser() — otherwise an attacker can forge a cookie and impersonate any user.
getClaims(): When a project has an asymmetric JWT signing key (RS256/ES256) enabled, Supabase Auth's JWTs can be verified locally with a public key, and getClaims() can validate tokens securely without a network request — ideal for high-frequency call paths.
4. Parsing the Stripe Webhook Body Before Verification
await request.json() re-serializes the payload, which breaks the signature check. Agents "fix" this by deleting the check. Instead, read the raw text:
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 });
// Signature verification needs the raw body. 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 auth middleware/proxy so it doesn't get redirected to the login page.
Rule: Webhook handlers read request.text() and verify the signature before processing the payload.
Why the raw body is required to verify the signature: Stripe uses HMAC-SHA256 to sign the raw bytes (raw body) of the webhook request and passes it in the Stripe-Signature header. request.json() parses and then re-serializes, which can change field order, whitespace, and escaping — causing signature verification to fail. Many tutorials or AI agents respond to the failure by simply deleting the signature-verification code, which lets anyone forge a "payment succeeded" event.
Runtime requirements: In the Next.js App Router, you must explicitly set export const runtime = "nodejs", because the Edge Runtime consumes the request body by default; you also need to make sure your auth middleware doesn't intercept the /api/webhooks/stripe path, or requests will be redirected to the login page.
5. Non-Idempotent Webhooks
Stripe retries deliveries and may send an event multiple times. Record each event id with a primary key and skip 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 real DB errors also matters: it tells Stripe to retry later instead of silently losing the event.
Rule: Make every webhook idempotent on event.id; use upsert to handle state.
6. Granting Access From the Success Redirect
/account?checkout=success is just a URL — anyone can visit it. Access must come from subscription state mirrored by the webhook, 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 the user at checkout creation (client_reference_id: user.id) so the webhook knows who the subscription belongs to — never take the user id or price from the request body.
Rule: Paid access comes only from rows written by the webhook. Prices come from env/server config, never from the client.
7. Open Redirects in Auth Callbacks
Magic links and OAuth callbacks often accept a ?next= parameter, and agents pass it straight to redirect(), turning 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 coming from user input goes through safeNext().
Bonus: Fail Loudly on Bad Env
A missing STRIPE_WEBHOOK_SECRET should not become undefined at runtime. Use lazy zod validation (so next build still works without secrets):
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)); }
Wrap-Up
These aren't rare problems. They're the inevitable shortcuts agents take when "make the error go away" is the only goal. Write the rules where the agent reads them every time (AGENTS.md, CLAUDE.md, .cursor/rules), and most of these issues get avoided before they're ever written down.
Disclosure: I made these resources. I turned this into a free 30-point Supabase + Stripe pre-launch security checklist. If you want the full rules file plus the reference app these snippets came from, there'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.
One-line Summary
AI agents often take shortcuts in Next.js+Supabase+Stripe apps, creating 7 classes of security holes that pass a quick click-through test.
Paragraph Summary
The article lists the 7 most common security vulnerabilities AI coding agents introduce when building Next.js + Supabase + Stripe apps, including: RLS policies written as using (true) that are effectively toothless, using the service-role key to bypass RLS, trusting getSession() on the server, parsing the JSON body before verifying the Stripe webhook signature (which breaks it), non-idempotent webhooks, granting access from a URL success redirect, open redirects in auth callbacks, and silently failing when env vars are missing. Each vulnerability comes with copy-pasteable fix snippets and a clear rule, such as: use getUser() for server authorization, webhooks must read request.text() and verify the signature, and paid access must be determined only by database state written by the webhook. The author recommends writing these rules into AGENTS.md, CLAUDE.md, or .cursor/rules so agents avoid most problems before they write any code.
Related articles

Build an AI Super Employee System at Zero Cost: A Full Breakdown of Automated Agent Workflows
A deep breakdown of the AI super employee system demoed on Bilibili: agent orchestration, digital human voiceovers, map and enterprise lead generation, 24/7 customer service, and legal modules. We analyze the business logic and compliance risks behind zero-cost signup to help entrepreneurs judge the real ROI of this AI digital employee system.

The Essential Difference Between Chatbots and AI Agents: Crossing from Conversation to Execution
What's the difference between a chatbot and an AI agent? Based on the MCP series on the Learn Tech channel, this article uses examples like booking flights and summarizing notes to explain: a chatbot only gives answers, while an agent actually completes tasks through a think-act-observe loop. It breaks down the four core components—model, tools, memory, and goal—as well as MCP's role as a bridge, and discusses agents' applicable boundaries and permission risks.

Free GitHub Repo: 342 Hours of AI Courses, from Math to MCP Servers
A free GitHub repo offers 342 hours of AI courses: 523 lessons across 20 phases, from math fundamentals to agent building and MCP servers. Nearly 100,000 learners have started.