Short answer: before you invite real users, assume the AI-generated app is unsafe until you prove three things: every exposed Supabase table has RLS plus tight grants, every secret key stays off the browser bundle, and Stripe entitlements come only from verified server events. The 12 checks below are the minimum. Paste the audit prompts into Claude Code or Cursor and do not ship until each check has a concrete pass signal.
Last updated: October 15, 2026. Grounded in Supabase Row Level Security, Supabase API keys, Supabase production checklist, and Stripe webhooks.
I keep seeing the same launch pattern: a weekend vibe-coded SaaS, a pretty landing page, then a soft launch where someone else’s rows show up in the network tab. The agent did not “forget security” on purpose. It optimized for a demo that works while you are signed in as the only user. Production is a different game.
If your stack is Next.js + Supabase + Stripe (the same shape as the CLAUDE.md SaaS template), these twelve checks cover most of the blast radius. They also work for Cursor-generated and Lovable-exported apps. The difference is not the framework. It is whether you verified access as a second user and as a signed-out visitor.
Why AI-generated apps leak data (the 3 usual causes)
Almost every vibe-coded breach I audit collapses into one of three mistakes:
- RLS is off, or policies exist but grants are still wide open. Supabase is clear: a table in an exposed schema without RLS is readable and writable by any role with a grant. Policies do not revoke grants. You need both.
- A service-role / secret key ships in the browser. Publishable keys are meant to be public. Secret keys bypass RLS. Anything prefixed
NEXT_PUBLIC_orVITE_is assumed public. - Billing trusts the client. The success URL, a plan id in query params, or a client-side “mark paid” call grants access without verifying a Stripe webhook signature on the raw body.
Fix those three and you are already ahead of most weekend launches. The rest of the checklist is how you prove it.
The 12-point checklist
| # | Check | Pass signal |
|---|---|---|
| 1 | RLS enabled on every table in exposed schemas | Dashboard Tables / Security Advisor shows no unprotected table |
| 2 | Grants match the product (revoke, then grant least privilege) | anon / authenticated only hold the operations you intend |
| 3 | One policy per operation (select / insert / update / delete) | No single FOR ALL USING (true) on private data |
| 4 | Second-user and anon tests through the real API | Other user’s rows never appear; anon cannot write private tables |
| 5 | Publishable key only in the browser client | No service_role / sb_secret_ / SUPABASE_SECRET_KEY in client bundles |
| 6 | Server routes that use a secret key still check the caller | Privilege is not “secret key present = do anything” |
| 7 | Stripe prices and plan ids live only on the server | Client cannot invent a cheaper price id and get it |
| 8 | Webhook signature verified on the raw body | Invalid / missing signature returns 4xx and changes nothing |
| 9 | Entitlements are idempotent by Stripe event id | Replay of the same event does not double-grant |
| 10 | Auth session cookies / tokens are HttpOnly where applicable | No access token sitting in localStorage “for convenience” |
| 11 | Storage buckets are not public by accident | Private objects require a signed URL or authenticated policy |
| 12 | Secrets are rotated if they ever hit git, screenshots, or chat logs | Dashboard rotate + scrub history; treat the window as exposed |
Print this table. Tick a row only when you have the pass signal, not when the agent says “done.”
Database: RLS and grants
Supabase runs two checks. Grants decide whether a role can run an operation on the table at all. Policies decide which rows that operation applies to. On many existing projects, new tables in public still start with full grants to anon, authenticated, and service_role. Enabling RLS without cleaning grants is how you get “I thought I locked it” bugs.
For each private table, the shape I want in a migration is:
alter table public.projects enable row level security;
revoke all on table public.projects from anon, authenticated;
grant select, insert, update, delete on table public.projects to authenticated;
create policy "owners select own projects"
on public.projects for select
to authenticated
using ( (select auth.uid()) = user_id );
create policy "owners insert own projects"
on public.projects for insert
to authenticated
with check ( (select auth.uid()) = user_id );
create policy "owners update own projects"
on public.projects for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
create policy "owners delete own projects"
on public.projects for delete
to authenticated
using ( (select auth.uid()) = user_id );
Notes that matter in practice:
- Prefer
(select auth.uid())over bareauth.uid()so Postgres can cache the result per statement (Supabase documents this). - Updates need both
using(which existing rows) andwith check(what the row may become), or a user can reassignuser_id. - Views bypass RLS by default unless you set
security_invoker = true(Postgres 15+). Treat every view like a table. - Prefer
supabase test dbwith a per-table test undersupabase/tests/over “it looked fine in the dashboard.”
How to verify without kidding yourself: open an incognito window, call the REST endpoint with only the publishable key, then again as a second signed-in user. Private tables should return empty or a permission error, not someone else’s rows. Do not judge safety from the SQL editor or a service_role client. Those paths are not what the browser uses.
Secrets: keys in the frontend
Safe in the browser (with RLS):
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
# legacy name you may still see:
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
Server only (never NEXT_PUBLIC_ / VITE_):
SUPABASE_SECRET_KEY=sb_secret_...
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
OPENAI_API_KEY=sk-...
Pitfall I keep hitting in agent-generated PRs: the model puts the service role into a shared lib/supabase.ts “so server and client can import the same file,” then a client component imports that module and the bundler ships the secret. Split clients on purpose. One browser client. One server client. No shared factory that accepts either key.
If a secret ever landed in git, a Discord screenshot, or an AI chat export, rotate it in the dashboard immediately. Assume the old value was used.
Payments: Stripe webhooks and server-side prices
Stripe’s own docs are blunt: verify every webhook with the raw payload, the Stripe-Signature header, and that endpoint’s whsec_ secret. Test and live secrets differ. Local Stripe CLI secrets differ from the Dashboard endpoint secret.
Minimum handler rules:
- Read the raw body (not a re-serialized JSON object).
- Call
stripe.webhooks.constructEvent(rawBody, signature, webhookSecret). - On bad or missing signatures, return 4xx and change no entitlements.
- On success, upsert by
event.idso retries are safe. - Grant paid access only from verified Checkout Session / invoice / subscription state, never from
?success=truein the browser.
Copy-paste prompt for your coding agent:
Audit our Stripe integration for launch safety.
1. Find every place we mark a user as paid or change their plan.
2. Confirm entitlements are only granted inside a webhook handler
(or a server route that retrieves the Checkout Session with the
secret key), never from a success URL or client-supplied plan id.
3. Confirm the webhook uses the raw request body and verifies
Stripe-Signature with the endpoint whsec_ secret.
4. Confirm we store processed event ids and ignore duplicates.
5. List every STRIPE_* env var and whether it can reach the browser bundle.
6. Propose the smallest PR that closes any gap. Do not refactor unrelated code.
In test mode, complete one purchase, replay the event, and send a forged signature. Only the first genuine event should grant access once.
Auth, email, and storage
These are the quiet failures that show up after you fix RLS:
- Auth: prefer cookie-based sessions for server-rendered apps. If you must use tokens in the browser, keep refresh logic boring and never log full JWTs.
- Email / SMTP: magic-link and invite flows should not leak whether an email exists in error messages. Rate-limit signup and password reset.
- Storage: a “public” bucket is public. Private user uploads need policies keyed on
auth.uid(), or signed URLs minted on the server. List the buckets before launch the same way you list tables. - Realtime / publications: replication of sensitive tables without RLS is another leak path. Supabase’s production checklist calls this out explicitly.
Copy-paste audit prompts for your AI agent
Run these in a clean chat with your repo open. One prompt per concern beats one giant “make it secure” request (see also how to keep Claude Code context lean).
Prompt A – schema and RLS
You are doing a pre-launch security audit. Do not rewrite the app.
1. List every table and view in exposed schemas.
2. For each one: is RLS enabled? Which grants do anon and authenticated hold?
3. Paste the policies. Flag FOR ALL, using(true), missing with check on updates,
and policies that trust a client-supplied user_id or tenant_id.
4. Write the smallest SQL migration that enables RLS, revokes excess grants,
and adds owner-scoped policies for private tables.
5. Add a supabase/tests/*.sql sketch for anon deny + owner allow + second-user deny.
Prompt B – secret inventory
Search the repo and env examples for service_role, sb_secret_, SUPABASE_SECRET,
STRIPE_SECRET, whsec_, sk_live, sk_test, and any NEXT_PUBLIC_/VITE_ secret-looking values.
Output a table: name | file | client or server | risk | fix.
Then open a PR that splits browser vs server Supabase clients if needed.
Prompt C – end-to-end abuse cases
Write a manual test script (no new framework) for:
- signed-out user hits private REST endpoints
- user A tries to read/update user B's row by id
- Stripe webhook with bad signature
- Stripe webhook replay of the same event id
- success URL hit without a paid Checkout Session
For each case, state expected status/body and where to look in logs.
Keep project rules in AGENTS.md (with a thin CLAUDE.md that points at it) so every tool reads the same “never put secrets in NEXT_PUBLIC_” rule. Details: CLAUDE.md vs AGENTS.md.
FAQ
Is the Supabase publishable / anon key a leak? No. It is designed to ship in the browser. It is only safe when RLS and grants are correct. The secret / service-role key is the one that must never ship.
We enabled RLS. Why can anon still insert? Because grants still allow it. Revoke, then grant only what you need. Policies do not remove grants.
Do I need this if I only have ten beta users? Yes. Beta users are still other people’s data. The blast radius is smaller, not zero.
Can Claude Code or Cursor “just secure the app”? (Here is how the tools compare for building.) They are good at drafting policies and splitting clients. They are bad at being the second user. You still have to run the anon / second-user / forged-webhook checks yourself.
What about Lovable or other vibe builders? Same checklist. Export or connect the Supabase project, then verify RLS and Stripe on the real backend. A pretty UI does not imply locked tables.
Further reading
- CLAUDE.md Template for Indie SaaS Projects – keep security rules in the repo so agents stop reintroducing
NEXT_PUBLIC_secrets - CLAUDE.md vs AGENTS.md – one shared rules file across Claude Code, Codex, and Cursor
- How to Reduce Claude Code Token Usage – run audits in a clean session so context noise does not hide findings
- Supabase: Row Level Security, API keys, Going to production
- Stripe: Webhooks
Ship the checklist, then invite users. Not the other way around.