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.
- [ ] RLS is enabled on every table (not just some)
- [ ] Every table with user data has a SELECT policy checking
auth.uid() = user_id - [ ] Every INSERT policy sets
user_id = auth.uid()— not just accepts any user_id - [ ] UPDATE and DELETE policies check ownership
- [ ] No table uses the service role key from the client bundle
- [ ] Storage bucket policies are configured — not open to all authenticated users
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 point to production domain, not localhost or preview URL
- [ ] Password reset email links to production domain
- [ ] Session refresh is handled —
onAuthStateChangeor equivalent is wired - [ ] Protected routes actually check auth (not just hide a button)
- [ ] Magic link / OTP expiry is configured
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)
- [ ] No database URL is prefixed
NEXT_PUBLIC_orVITE_(would expose it to the browser) - [ ] No secret key (Stripe
sk_, Supabase service role) is prefixedNEXT_PUBLIC_orVITE_ - [ ]
.envfiles are in.gitignoreand not committed - [ ] Different env vars exist for development and production (not the same Stripe test/live key)
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)
- [ ] Every API route or serverless function checks authentication before processing
- [ ] Resource endpoints verify ownership (user can only access their own resources)
- [ ] Rate limiting exists on auth endpoints (prevent brute force)
- [ ] No endpoint accepts user input and uses it directly in a database query (SQL injection risk)
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)
- [ ] Webhook endpoint verifies the Stripe signature (
stripe.webhooks.constructEvent) - [ ]
invoice.payment_failedis handled — access revoked or dunning triggered - [ ]
customer.subscription.deletedis handled — access revoked - [ ] Webhook handler is idempotent — duplicate events don't create duplicate state
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)
- [ ] Dependencies are audited with
npm audit— no critical vulnerabilities - [ ] AI-generated code has been reviewed for hardcoded values (API keys, passwords, user IDs)
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.