afterbuild/ops
Resource

AI app security checklist — 25 checks before launch.

AI builders move fast and leave security for later. Later is now. Here are the 25 checks that catch the most critical gaps in AI-built apps before real users find them.

By Hyder ShahFounder · Afterbuild LabsLast updated 2026-04-15

Security in AI-built apps is not a theoretical problem. The widely-reported February 2026 Lovable/Supabase RLS disclosure — summarized in our 2026 vibe-coding research — captured the failure mode at scale. Industry benchmarks (see Veracode's State of Software Security) put AI-code vulnerability rates close to half, with cross-site scripting and log injection — both in the OWASP Top 10 — failing most of the time. These are not edge cases. They are the baseline output.

The good news: the failure modes are repetitive. The same twenty-five gaps appear in almost every AI-built app we audit. If you can run these twenty-five checks and close each one, you are ahead of more than 95% of AI-generated applications in production today. Below is the complete list, grouped by category, with an explanation for each item and the specific fix.

1. Supabase RLS (6 checks)

Row-Level Security is the single most important security feature you will configure in an AI-built app, and it is the one the AI reliably gets wrong.

Why RLS on every table

When RLS is disabled on a Supabase table, the anon key (which is shipped to every browser that loads your app) has full read and write access to that table. A curious user can open the browser devtools, grab the supabase client, and run supabase.from('any_table').select('*'). If RLS is off, they get everything. Check in the Supabase dashboard: Table Editor → select each table → ensure the “RLS enabled” badge is present. If not, click “Enable RLS.”

Why SELECT policies per table

Enabling RLS without writing policies locks the table completely — nobody can read anything, including the legitimate owner. So you need at least one SELECT policy per table. The canonical policy for user-owned data is USING (auth.uid() = user_id), which says “a row is visible if the current authenticated user's ID matches the row's user_id column.” If your schema has shared resources, the policy includes a join against the membership table.

Why INSERT policies must set user_id

An easy mistake: write an INSERT policy that says WITH CHECK (true) because “only authenticated users can hit this anyway.” The problem is the client sends the user_idvalue, and an attacker can send any value they like — including another user's ID. The correct policy is WITH CHECK (auth.uid() = user_id), which rejects any insert that claims a different user.

Service role keys do not belong in the client

The service role key bypasses RLS entirely. It is intended for server-side use only. Search your repo for SUPABASE_SERVICE_ROLE — if it is imported from anywhere inside app/ or components/ on the client, or prefixed with NEXT_PUBLIC_ or VITE_, it is exposed. Move it to server-only code (route handlers, server actions) and re-deploy.

Storage bucket policies

Supabase Storage has its own policies, separate from table RLS. The default for a new bucket is “authenticated users can read,” which for a multi-tenant app means any logged-in user can read any file in the bucket. Configure policies per bucket: users read only files in their own folder, users write only to their own folder. The pattern is (storage.foldername(name))[1] = auth.uid()::text.

2. Authentication (5 checks)

OAuth redirect URLs

AI builders create OAuth flows with the preview URL hardcoded. Once you deploy, the OAuth provider redirects users back to https://stackblitz-something.com instead of your real domain, and the auth flow dies. Fix in two places: the OAuth provider dashboard (Google Cloud Console, GitHub OAuth Apps) must have your production domain in the redirect URI list, and the Supabase Auth “Site URL” and “Redirect URLs” settings must include it.

Password reset email templates

The default Supabase password reset email includes a link to {{ .SiteURL }}/auth/callback?.... If your Site URL is still the preview domain, the email bounces users nowhere. Change the Site URL in Supabase Auth settings and send yourself a test reset email to verify the link works.

Session refresh

Supabase sessions expire after one hour by default. Without a refresh mechanism, users are randomly bounced to login mid-task. The fix in Next.js is a middleware that calls supabase.auth.getUser() on every request, which refreshes the cookie. In React Router / Vite apps, it is onAuthStateChange plus correct session handling in your root provider.

Protected routes actually check auth

Hiding the navigation button to /admin does not protect /admin. An attacker types the URL into the browser and hits the page. Every protected route must check auth server-side (in Next.js: getUser() at the top of the server component or route handler, and redirect if null). Audit by trying to hit every protected URL while logged out.

Magic link expiry

Default magic link lifetime in Supabase is 24 hours. That is too long for a password-reset-grade link. Configure it to one hour (or less) in Supabase Auth settings. Same for OTP codes if you use phone auth.

3. Environment variables (4 checks)

The public prefix trap

Next.js exposes any env var prefixed NEXT_PUBLIC_ to the browser bundle. Vite does the same with VITE_. AI builders sometimes prefix things liberally to “make them work,” which can inadvertently ship your database connection string, Stripe secret key, or API credentials to every visitor. Search your code for every env var; anything with a sk_, database URL, or service role token must not be in a public-prefixed variable.

.env in .gitignore

Open .gitignore. Verify it includes .env, .env.local, .env.production, and any other env file variant you use. Run git log -- .env to check no env file was ever committed. If one was, you must rotate every secret in it and then cleanse the git history (or accept the leak).

Dev vs production vars

Using the same Stripe live key in development and production means test transactions hit your real account. Using the same Supabase project for both means seed data and real data mix. Create two environments: a dev Supabase project, a Stripe test mode, local-only keys for development; production equivalents only deployed to the production host. Never cross the streams.

4. API endpoints (4 checks)

Authentication at every endpoint

Every API route that does anything other than public content must start with an auth check. In Next.js: const user = await getUser(); if (!user) return new Response(null, { status: 401 }); at the top of the handler. AI-generated routes often skip this on the assumption that “the frontend only calls it when logged in” — which is irrelevant, because anyone can hit the URL directly.

Ownership checks on resource endpoints

An endpoint like GET /api/projects/[id] must check that the project belongs to the authenticated user. The AI sometimes writes select * from projects where id = $1 with no user check — any logged-in user can fetch any project by iterating IDs. Always join on user_id or rely on RLS to enforce it. If you rely on RLS, verify RLS is actually enabled (Section 1).

Rate limiting on auth

Login and password reset endpoints without rate limiting are vulnerable to credential stuffing. Supabase Auth has built-in rate limits on its hosted endpoints, but any custom auth logic you wrote (magic link senders, custom login handlers) needs its own. Upstash Rate Limit, Vercel KV, or a simple Redis counter — the implementation is small; the protection is large.

SQL injection

When using the Supabase client with typed queries (.from().select() etc.), SQL injection is mostly impossible because values are parameterized. The risk appears when AI code drops into raw SQL — rpc() calls or custom functions — and concatenates user input into a query string. Search for any $${-interpolated SQL and fix it to use parameters.

5. Stripe and payments (4 checks)

Signature verification

A public webhook URL without signature verification accepts any POST. An attacker can send a fake checkout.session.completed payload with their own user ID and grant themselves premium. Follow Stripe's webhook signing guide and use stripe.webhooks.constructEvent(rawBody, signature, webhookSecret). Read the raw request body, not parsed JSON — Stripe computes the signature over the raw bytes, and most web frameworks default to parsed JSON which fails verification.

Handle payment failure

When a recurring payment fails, Stripe fires invoice.payment_failed. If you ignore the event, the user keeps access indefinitely while Stripe retries (typically 21 days). You want to either start dunning (notify the user, retry their card, require action) or revoke access after a grace period. Either is defensible; doing nothing is not.

Handle subscription cancellation

When a subscription ends (user cancels, payment failures exhausted, manual delete), Stripe fires customer.subscription.deleted. Your handler must update the subscriptions table to mark the user as non-paying. Otherwise former customers keep access forever. Test this by cancelling a test subscription in the Stripe dashboard and verifying access is revoked in your app.

Idempotency

Stripe delivers webhooks at-least-once. If your handler times out or returns a 500, Stripe retries. Your code must produce the same database state whether an event is processed once or three times. Standard pattern: a processed_events table with a unique constraint on stripe_event_id; before processing, insert the ID and catch the unique violation to skip duplicates.

6. Code and dependency security (2 checks)

Dependency audit

Run npm auditin your repo. Fix every “critical” and “high” finding before launch. Most are one npm audit fix away. For the ones that aren't, read the advisory and decide: update the direct dependency, replace it, or accept the risk with a documented rationale. Do not launch with known critical CVEs in your dependency tree.

Hardcoded values

AI builders occasionally burn values into the code during development and forget to clean them up: a test API key, a test user ID, a webhook secret, a debug bypass that says if (email === 'test@example.com') return true;. Grep for these patterns. Search for sk_test, sk_live, bypass, debug, TODO, and your own email address. Anything you find, move to env vars or delete.

How to run this checklist

Print the checklist. Go item by item. For each one, open the relevant tool (Supabase dashboard, Stripe dashboard, your code editor) and verify. Do not guess; do not assume the AI did it; do not skip because you're tired. Every item you skip is an item that will bite you later, probably when you're least able to handle it.

If any item feels ambiguous or you cannot verify it yourself, that is exactly what the free audit covers. We run the full twenty-five checks and deliver a written report rating every finding Critical, High, or Medium. You keep the report whether or not you engage us for the fix work. The report is yours.

See how rescue works →

FAQ
Which of these 25 checks is most critical?
Supabase RLS — specifically, whether every table has SELECT policies that check auth.uid() = user_id. In the Register's 2026 study, 94% of AI-built apps had at least one table with no policies at all. The consequence is any authenticated user reading any other user's data. This is not a theoretical risk; it's the most common class of production incident in AI-built apps.
How long does it take to run these 25 checks?
An experienced developer can run the full list in 2–4 hours. We run it as the Rescue Diagnostic, delivered in 48 hours, for free.
Do AI builders ever pass this checklist without human review?
Never on the full list. AI builders get some items right some of the time — Supabase Auth is usually wired correctly, OAuth sometimes works — but the combination of RLS + webhook verification + API auth + env var hygiene has never passed on an unreviewed AI-built app in our audit history.
My app passed all 25 checks. Is it production-ready?
Probably yes for a basic launch. This checklist covers the failure modes that cause breaches and financial errors. It doesn't cover performance (N+1 queries), observability (error monitoring, logging), or maintainability (test coverage, code organization). Those matter less for day one and more for month six.
Can I run these checks myself?
Yes — that's why we published this list. For each item, there's a Supabase dashboard check, a code search pattern, or a Stripe CLI command. If you're comfortable in the Supabase dashboard and can read TypeScript, you can run most of these in an afternoon. What we do that's harder to do yourself: test each RLS policy with a real user token, verify the webhook handler with a Stripe CLI test, and read the code with fresh eyes for hardcoded values the AI buried in a file you haven't looked at.
Next step

Free security audit for your AI app

We run all 25 checks against your app and deliver a written findings report in 48 hours. Every finding is rated Critical / High / Medium. Free, no commitment.

Book free diagnostic →