I build businesses that solve the technical problems people run into, and this one keeps coming up. AI coding agents are great at "make it work" and bad at "make sure nobody else can." If you built your app with Cursor, Claude Code, Lovable or Bolt, the same handful of holes show up again and again: tables anyone can read, secret keys in the frontend, and payment flows you can unlock with curl.
None of these need a security background to find. Here are seven checks you can run in about ten minutes, against your live app, before you post that launch tweet. Most take one command.
Your Supabase anon key is public by design. It's in your frontend bundle, and anyone can copy it. That's fine only if row-level security (RLS) is on for every table and the policies are real.
So test what a stranger sees. Grab your project URL and anon key from the browser's Network tab, then:
curl "https://YOUR_PROJECT.supabase.co/rest/v1/profiles?select=*&limit=5" \
-H "apikey: YOUR_ANON_KEY" \
-H "Authorization: Bearer YOUR_ANON_KEY"
Repeat for every table. If rows come back while you're logged out, either RLS is off or a policy says using (true).
To see every table at once, run this read-only query in the Supabase SQL editor:
select c.relname as table_name, c.relrowsecurity as rls_enabled
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r'
order by rls_enabled, table_name;
Any false is a table anyone can read and write. The usual fix is an owner-only policy:
alter table public.notes enable row level security;
create policy "owner can read" on public.notes
for select using (auth.uid() = user_id);
(Add matching insert/update/delete policies, using with check on insert and update.)
RLS stops strangers. The next bug is logged-in users reading each other's data, often through your own API routes, which may use a service key and skip RLS entirely.
If B's data comes back, your API checks login but not ownership. Every read, update and delete needs a "does this row belong to this user?" check on the server. Hiding a button in the UI doesn't count.
Anything prefixed NEXT_PUBLIC_, VITE_, EXPO_PUBLIC_ or REACT_APP_ is copied into the bundle every visitor downloads. Agents love to "fix" a missing-key error by adding that prefix.
First, check your env files for secret-sounding public variables:
grep -E "^(NEXT_PUBLIC_|VITE_|EXPO_PUBLIC_|REACT_APP_)[A-Z_]*(SECRET|SERVICE|PRIVATE|OPENAI|ANTHROPIC)" .env*
Then build and search the output (dist, build or .next/static) for real key shapes:
npm run build
grep -rEo "sk_live_[A-Za-z0-9]+|sk-proj-[A-Za-z0-9_-]+|sk-ant-[A-Za-z0-9_-]+|sb_secret_[A-Za-z0-9_-]+" dist .next/static 2>/dev/null
The Supabase service_role key is a JWT, so the word "service_role" usually isn't visible in plain text. Find any JWTs in the bundle and decode the middle part:
grep -rEoh "eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+" dist .next/static 2>/dev/null | sort -u \
| while read t; do echo "$t" | cut -d. -f2 | base64 -d 2>/dev/null; echo; done
"role":"anon" is fine. "role":"service_role" means anyone can bypass all of your RLS. Move it to server-only code and rotate it.
Also check git history, because deleting a file doesn't delete the commit:
git log -p | grep -E "sk_live|sk-ant|sk-proj|service_role"
If anything turns up, rotate the key at the provider. Removing it from the code revokes nothing.
Two common payment bugs:
/success?session_id=... is just a URL. Anyone can type it. Grant access only from a verified webhook.checkout.session.completed event.
Signature checks need the raw request body. A parsed-then-re-stringified JSON body will fail verification. In a Next.js route handler:
export async function POST(req: Request) {
const body = await req.text(); // raw body, not req.json()
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("Invalid signature", { status: 400 });
}
// Idempotency: Stripe retries, so the same event can arrive twice.
// Skip event.id values you've already processed (store them in a table).
if (event.type === "checkout.session.completed") {
// grant access here, looked up from the session, not from the browser
}
return new Response("ok");
}
Test it:
curl -i -X POST https://yourapp.com/api/stripe/webhook -d '{"type":"checkout.session.completed"}'
Then pay in test mode and close the tab before the redirect. Access should still unlock. Resend the same event from the Stripe dashboard and confirm nothing gets granted twice. Remember that test and live webhook endpoints have different whsec_ secrets.
While you're there, make sure prices come from the server (a price ID you look up), not from an amount the browser sends.
Static hosts serve whatever ends up in the output folder. A stray .env or .git folder there is public.
for p in .env .env.local .env.production .git/config .git/HEAD .DS_Store; do
printf "%-18s" "$p"; curl -s -o /dev/null -w "%{http_code}\n" "https://yourapp.com/$p"
done
Each should be 404. If you get 200, open the URL. SPAs often return index.html for every path, which is harmless. Real file contents are not.
An LLM route with no auth and no limit is a blank check. One script running overnight can produce a very large bill.
max_tokens and an input length limit.
Table RLS doesn't cover files. Supabase Storage uses policies on storage.objects, and a bucket marked public serves every file to anyone with the URL.
Check each bucket's public toggle, then add a policy for private ones, for example "users read only their own folder":
create policy "own files" on storage.objects for select
using (bucket_id = 'uploads' and auth.uid()::text = (storage.foldername(name))[1]);
Test it by opening a file URL from a private bucket in an incognito window. It should fail.
I got tired of running these checks by hand, so I wrote a small scanner for the code-level ones, and it's free. It's one file with zero dependencies (Node 18+), it runs locally, and nothing gets uploaded. It looks for keys in source and git history, secret-looking public env vars, tables created without RLS, using (true) policies, unverified Stripe webhooks and client-side LLM calls. It writes a report with a fix prompt you can paste into your coding agent, and it exits non-zero on critical findings so it can run in CI. Read the source first, then:
curl -O https://preflight-kit.pages.dev/preflight.mjs
node preflight.mjs
A scanner can't see your dashboard settings or your logic, though. That's why the manual checks above (logged-out queries, the A/B replay, the webhook test) still matter.
The scanner is part of something I built called PreFlight Kit. The paid kit ($19) adds 12 copy-paste audit prompts for your coding agent (IDOR, RLS, never-trust-the-client, payments, AI cost caps, prompt injection and more), an AGENTS.md / CLAUDE.md security section plus a Cursor rule so your agent stops reintroducing these bugs, a Supabase RLS audit query and policy templates, a 30-point launch checklist, and an "I leaked a key" runbook.
If that's useful, code LAUNCH10 takes it to $9 through Sunday, Nov 1: preflight-kit.pages.dev. If not, the seven checks above cover the most common holes. Run them before you launch.
Disclosure: I make PreFlight Kit.