DEV Community

Stack & Ship
Stack & Ship

Posted on Fully Autonomous

28 launch checks for AI-built Next.js + Supabase + Stripe apps

We build Next.js + Supabase + Stripe apps with AI coding agents. The agents are fast, and they make the same handful of mistakes over and over. This is the list we run before anything goes live. It has 28 checks in five groups: keys, auth, row level security, Stripe, and hardening. Each one comes with what to grep for and what the fix looks like.

You don't need any tool for this. Most checks are a search plus five minutes of reading.

1. Keys and secrets (K1–K5)

K1: Live secrets in source (critical). Search for sk_live_, rk_live_, sb_secret_, and service-role JWTs. If you find one, rotate it. Deleting the line doesn't help, because it's still in git history.

git grep -nE 'sk_live_|rk_live_|sb_secret_' $(git rev-list --all) | head
Enter fullscreen mode Exit fullscreen mode

K2: Server secrets in a "use client" file (critical). That covers the admin client, the Stripe SDK, and any secret env var. Anything in a client file ships to every browser.

K3: NEXT_PUBLIC_ on a secret (critical). NEXT_PUBLIC_*SECRET, *SERVICE_ROLE, *PRIVATE and *WEBHOOK* values all get inlined into the client bundle.

K4: .env* files with values tracked by git (high). Commit a .env.example with empty values and nothing else.

K5: A lib/ module that reads a secret without server-only (medium). One accidental client import is enough to leak it.

// lib/stripe.ts
import "server-only";
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
Enter fullscreen mode Exit fullscreen mode

2. Auth (A1–A5)

A1: auth.getSession() in server code (high). It reads the cookie without verifying it, so it can be spoofed. On the server, use getUser() (or getClaims()).

const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect("/login");
Enter fullscreen mode Exit fullscreen mode

A2: Server actions that never check the user (high). Every "use server" export is a public POST endpoint, whether or not your UI shows a button for it.

A3: redirect() to an unvalidated query param (high). That's an open redirect, and it lets someone phish through your domain. Allow only same-site relative paths:

export function safeNext(next: string | null) {
  if (!next || !next.startsWith("/") || next.startsWith("//") || next.startsWith("/\\")) return "/";
  return next;
}
Enter fullscreen mode Exit fullscreen mode

A4: The admin (service-role) client outside webhooks, cron, or billing code (high). It bypasses RLS. If user input picks the row, you have an IDOR.

A5: Role or plan read from user_metadata (medium). Users can edit their own metadata. Keep roles and plans in a table that users can't write to.

3. Row level security (R1–R7)

R1: A table in public without RLS (critical). Without it, anyone holding the public key can read and write the table.

create table public.notes (...);
alter table public.notes enable row level security;
Enter fullscreen mode Exit fullscreen mode

R2: using (true) or with check (true) (critical). This means everyone sees or writes every row. Agents love writing it to make an error go away.

R3: Write policies to anon or to public (high). These allow signed-out writes.

R4: Insert or update policies without with check (high). Without it, users can write rows they don't own.

create policy "own notes update" on public.notes
  for update to authenticated
  using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);
Enter fullscreen mode Exit fullscreen mode

R5: A security definer function without set search_path (high). That opens a privilege-escalation path through search_path.

create function public.do_thing() returns void
language plpgsql security definer set search_path = '' as $$ ... $$;
Enter fullscreen mode Exit fullscreen mode

R6: A view without security_invoker = true (medium). By default, views bypass RLS.

R7: auth.uid() not wrapped in (select ...) (low). This one is about performance, not security. Unwrapped, it's evaluated per row, and that hurts on large tables.

A side note on Supabase grants

This isn't one of the 28 checks, but it's worth knowing. Supabase's changelog schedules a change to default Data API grants for 30 Oct (see Supabase CLI PR #6337). If it goes ahead as scheduled, newly created tables may need explicit grant statements before the Data API can reach them. Check your own project's settings and the changelog, and don't assume either way.

4. Stripe (S1–S7)

S1: A webhook without constructEvent (critical). Without it, anyone can POST a fake "paid" event.

S2: request.json() before verification (critical). Parsing the body breaks the signature. Read the raw text instead:

export const runtime = "nodejs"; // S6
export async function POST(req: Request) {
  const body = await req.text();
  const sig = req.headers.get("stripe-signature")!;
  let event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return new Response("bad signature", { status: 400 });
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

S3: No idempotency on event ids (high). Stripe retries. Store event.id with a pending or completed status so a retry can't double-grant and a crashed event isn't skipped forever. If you reprocess pending events, the handlers need to be idempotent upserts keyed on Stripe ids, or claimed with an atomic lease. Side effects like emails or credit grants need their own dedupe key.

create table stripe_events (
  id text primary key,
  status text not null check (status in ('pending','completed')),
  created_at timestamptz default now()
);
Enter fullscreen mode Exit fullscreen mode

S4: Price or amount taken from the request (critical). That lets users pick their own price. Look up the price on the server from a key you control.

S5: Access granted on the success page (high). The success page doesn't confirm payment, and it can be replayed. Grant access from the webhook.

S6: A webhook on the edge runtime (medium). The signature code expects Node crypto. Set runtime = "nodejs".

S7: Billing period read from items.data[0] (low). Since API version 2025-03-31.basil, periods live on subscription items. With more than one item, items.data[0] gives you the wrong period or price.

5. Hardening (H1–H4)

H1: Route or action input with no schema validation (medium). It opens you to type confusion and mass assignment.

const Input = z.object({ title: z.string().max(200) });
const parsed = Input.safeParse(await req.json());
if (!parsed.success) return new Response("bad input", { status: 400 });
Enter fullscreen mode Exit fullscreen mode

H2: Raw error.message returned to the client (medium). It leaks SQL, schema, and internal details. Log the error on the server and return a generic message.

H3: No security headers in next.config (low). This leaves you open to clickjacking and MIME sniffing.

async headers() {
  return [{ source: "/(.*)", headers: [
    { key: "X-Frame-Options", value: "DENY" },
    { key: "X-Content-Type-Options", value: "nosniff" },
    { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
  ]}];
}
Enter fullscreen mode Exit fullscreen mode

H4: dangerouslySetInnerHTML (medium). If any of that content is user-controlled, it's an XSS hole. Sanitize it or render it as text.

Things grep won't find

Read the code for these:

  • IDOR: for every route that takes an id, write out the exact request that would read someone else's row.
  • Admin-client queries where user input picks rows or columns.
  • Updates and deletes that filter only by id.
  • Webhooks that identify the user by email alone. Use client_reference_id, metadata set on the server, or your customers table instead.
  • past_due, canceled, or incomplete statuses that still grant full access.
  • Public storage buckets you didn't mean to make public.
  • Unrate-limited custom auth endpoints.
  • Secrets that get logged.
  • npm audit --omit=dev.

Then check the dashboards. Make sure the Supabase Security Advisor is clean, the auth redirect URLs point to production, the Stripe live webhook secret is separate from the test one, and backups are on.

Why AI agents keep writing these

Most of these come from the agent fixing the error in front of it. "Permission denied" turns into using (true). A signature error turns into dropping verification. "Not authorized" turns into reaching for the service-role client. Each change makes the error go away, and none of them looks wrong in a diff.


Disclosure: we make AI Rules v2 (paid). It is a rules pack for Claude Code that steers the agent away from these patterns while it writes code. It has 18 skills, 6 subagents, and 3 hooks, backed by 21 hook tests. It costs $19 (UK $22.80 incl. VAT). Code DEVTO25 takes 30% off for DEV readers until 28 Oct, 11:00 AM PT: edtrackai.github.io/ai-rules

Top comments (0)