{"slug": "the-10-minute-security-check-before-you-launch-a-vibe-coded-app-supabase-stripe", "title": "The 10-minute security check before you launch a vibe-coded app (Supabase + Stripe)", "summary": "A developer published a 10-minute pre-launch security checklist for apps built with AI coding agents such as Cursor, Claude Code, Lovable and Bolt, targeting common Supabase and Stripe misconfigurations. The checks include curl probes to confirm row-level security is enabled on every table, grepping built bundles for leaked OpenAI, Anthropic, Stripe and Supabase service_role keys, scanning git history for committed secrets, and verifying Stripe payments through signed webhooks using the raw request body rather than a success-page URL.", "body_md": "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.\n\nNone 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.\n\nYour 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.\n\nSo test what a stranger sees. Grab your project URL and anon key from the browser's Network tab, then:\n\n```\ncurl \"https://YOUR_PROJECT.supabase.co/rest/v1/profiles?select=*&limit=5\" \\\n  -H \"apikey: YOUR_ANON_KEY\" \\\n  -H \"Authorization: Bearer YOUR_ANON_KEY\"\n```\n\nRepeat for every table. If rows come back while you're logged out, either RLS is off or a policy says `using (true)`.\n\nTo see every table at once, run this read-only query in the Supabase SQL editor:\n\n```\nselect c.relname as table_name, c.relrowsecurity as rls_enabled\nfrom pg_class c\njoin pg_namespace n on n.oid = c.relnamespace\nwhere n.nspname = 'public' and c.relkind = 'r'\norder by rls_enabled, table_name;\n```\n\nAny `false` is a table anyone can read and write. The usual fix is an owner-only policy:\n\n```\nalter table public.notes enable row level security;\ncreate policy \"owner can read\" on public.notes\n  for select using (auth.uid() = user_id);\n```\n\n(Add matching insert/update/delete policies, using `with check` on insert and update.)\n\nRLS 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.\n\nIf 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.\n\nAnything 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.\n\nFirst, check your env files for secret-sounding public variables:\n\n```\ngrep -E \"^(NEXT_PUBLIC_|VITE_|EXPO_PUBLIC_|REACT_APP_)[A-Z_]*(SECRET|SERVICE|PRIVATE|OPENAI|ANTHROPIC)\" .env*\n```\n\nThen build and search the output (`dist`, `build` or `.next/static`) for real key shapes:\n\n```\nnpm run build\ngrep -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\n```\n\nThe 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:\n\n```\ngrep -rEoh \"eyJ[A-Za-z0-9_-]+\\.eyJ[A-Za-z0-9_-]+\" dist .next/static 2>/dev/null | sort -u \\\n  | while read t; do echo \"$t\" | cut -d. -f2 | base64 -d 2>/dev/null; echo; done\n```\n\n`\"role\":\"anon\"` is fine. `\"role\":\"service_role\"` means anyone can bypass all of your RLS. Move it to server-only code and rotate it.\n\nAlso check git history, because deleting a file doesn't delete the commit:\n\n```\ngit log -p | grep -E \"sk_live|sk-ant|sk-proj|service_role\"\n```\n\nIf anything turns up, **rotate the key at the provider**. Removing it from the code revokes nothing.\n\nTwo common payment bugs:\n\n`/success?session_id=...` is just a URL. Anyone can type it. Grant access only from a verified webhook.`checkout.session.completed` event.\nSignature checks need the **raw** request body. A parsed-then-re-stringified JSON body will fail verification. In a Next.js route handler:\n\n``` js\nexport async function POST(req: Request) {\n  const body = await req.text(); // raw body, not req.json()\n  const sig = req.headers.get(\"stripe-signature\") ?? \"\";\n  let event;\n  try {\n    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);\n  } catch {\n    return new Response(\"Invalid signature\", { status: 400 });\n  }\n\n  // Idempotency: Stripe retries, so the same event can arrive twice.\n  // Skip event.id values you've already processed (store them in a table).\n\n  if (event.type === \"checkout.session.completed\") {\n    // grant access here, looked up from the session, not from the browser\n  }\n  return new Response(\"ok\");\n}\n```\n\nTest it:\n\n```\n# should return 400, not 200\ncurl -i -X POST https://yourapp.com/api/stripe/webhook -d '{\"type\":\"checkout.session.completed\"}'\n```\n\nThen 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.\n\nWhile you're there, make sure prices come from the server (a price ID you look up), not from an amount the browser sends.\n\nStatic hosts serve whatever ends up in the output folder. A stray `.env` or `.git` folder there is public.\n\n```\nfor p in .env .env.local .env.production .git/config .git/HEAD .DS_Store; do\n  printf \"%-18s\" \"$p\"; curl -s -o /dev/null -w \"%{http_code}\\n\" \"https://yourapp.com/$p\"\ndone\n```\n\nEach 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.\n\nAn LLM route with no auth and no limit is a blank check. One script running overnight can produce a very large bill.\n\n`max_tokens` and an input length limit.\nTable 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.\n\nCheck each bucket's public toggle, then add a policy for private ones, for example \"users read only their own folder\":\n\n```\ncreate policy \"own files\" on storage.objects for select\n  using (bucket_id = 'uploads' and auth.uid()::text = (storage.foldername(name))[1]);\n```\n\nTest it by opening a file URL from a private bucket in an incognito window. It should fail.\n\nI 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:\n\n```\ncurl -O https://preflight-kit.pages.dev/preflight.mjs\nnode preflight.mjs\n```\n\nA 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.\n\nThe 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.\n\nIf that's useful, code **LAUNCH10** takes it to $9 through Sunday, Nov 1: [preflight-kit.pages.dev](https://preflight-kit.pages.dev). If not, the seven checks above cover the most common holes. Run them before you launch.\n\n*Disclosure: I make PreFlight Kit.*", "url": "https://wpnews.pro/news/the-10-minute-security-check-before-you-launch-a-vibe-coded-app-supabase-stripe", "canonical_source": "https://dev.to/agentk_6588bb59c6339/the-10-minute-security-check-before-you-launch-a-vibe-coded-app-supabase-stripe-40n1", "published_at": "2026-10-29 17:06:22+00:00", "updated_at": "2026-10-29 17:20:17.789581+00:00", "lang": "en", "topics": ["ai-tools", "ai-agents", "developer-tools", "ai-products"], "entities": ["Supabase", "Stripe", "Cursor", "Claude Code", "Lovable", "Bolt", "OpenAI", "Anthropic"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/the-10-minute-security-check-before-you-launch-a-vibe-coded-app-supabase-stripe", "markdown": "https://wpnews.pro/news/the-10-minute-security-check-before-you-launch-a-vibe-coded-app-supabase-stripe.md", "text": "https://wpnews.pro/news/the-10-minute-security-check-before-you-launch-a-vibe-coded-app-supabase-stripe.txt", "jsonld": "https://wpnews.pro/news/the-10-minute-security-check-before-you-launch-a-vibe-coded-app-supabase-stripe.jsonld"}}