I ran a pre-launch audit on a deliberately broken vibe-coded app. Here are 10 findings and the prompt that fixes each one
Disclosure: I built LaunchVet, the tool I use in this article. The free core is MIT licensed. Every fix prompt below works in a plain Claude Code session, so you do not need the tool to use them. AI will write you an ap
Disclosure: I built LaunchVet, the tool I use in this article. The free core is MIT licensed. Every fix prompt below works in a plain Claude Code session, so you do not need the tool to use them.
AI will write you an app that runs. It will not tell you the database is open to anyone holding your public key. Veracode's 2025 GenAI Code Security Report (80 coding tasks, 100+ models) found AI-generated code introduced security vulnerabilities in 45% of test cases. That is test cases, not apps, and not all AI code, but it is enough reason to look before real users arrive.
So I made a demo app the way a rushed AI-assisted build often turns out: a resume generator called ResumeFlash, on Next.js, Supabase and Stripe, with the usual mistakes left in on purpose. Then I ran a pre-launch audit on it from inside Claude Code. The report came back with 28 findings: 7 critical, 14 high, 6 medium and 1 low. Verdict line: "NOT READY TO LAUNCH: fix the critical findings first."
Here are the 10 I think are the most relatable, with the file the audit pointed at, why it hurts, and the prompt from the report that fixes it. The prompts are shortened here. The full versions are in the report: https://github.com/Lt23live/launchvet/blob/main/examples/broken-app/launchvet-report.md
Paste a prompt into Claude Code, read the diff, commit. Do not accept a change you have not read.
Security
1. The Supabase service role key is shipped to every browser
Where: lib/supabaseClient.js:6. Critical.
The key was stored as NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY. Next.js copies every NEXT_PUBLIC_ value into the JavaScript each visitor downloads, and the service role key skips every database rule. Anyone who opens the site can copy it and read, change or delete all data for all users.
The Supabase service role key is exposed to the browser. Fix it:
1. In .env.local rename NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY to SUPABASE_SERVICE_ROLE_KEY.
2. Split lib/supabaseClient.js into two files. lib/supabaseClient.js exports only `supabase`, created with NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY. New file lib/supabaseAdmin.js starts with `import 'server-only';` and exports `supabaseAdmin` created with SUPABASE_SERVICE_ROLE_KEY.
3. Update every import of supabaseAdmin in app/api/** to the new file. Client components must import only lib/supabaseClient.js.
Do not change unrelated code. After the change, run `npm run build` and search the .next/static folder for the text "service_role"; it must not appear. Then tell me to rotate the service role key in the Supabase dashboard, because the old one has been public.
Rotate first, then fix the code. A key that has been public stays compromised after you hide it.
2. Two tables have no Row Level Security
Where: supabase/migrations/20261101000000_init.sql:20. Critical.
documents and subscriptions were created without enable row level security, and the migration even runs disable row level security on subscriptions with the comment "RLS kept blocking the dashboard". The anon key ships in your frontend by design. With RLS off, anyone holding it can read and edit every resume and every billing record. This is the most expensive mistake on the list.
Create a new Supabase migration in supabase/migrations/ that secures the documents and subscriptions tables. Remove nothing from the old migration; add a new file. In it:
1. alter table public.documents enable row level security;
2. alter table public.subscriptions enable row level security;
3. documents policies: select, update and delete allowed only when auth.uid() = owner_id; insert allowed only with check (auth.uid() = owner_id).
4. subscriptions policies: select allowed only when auth.uid() = user_id. No insert, update or delete policies for normal users; only the server (service role, used in the Stripe webhook) writes this table.
Do not change unrelated code. Then show me how to test with two different test users that user B cannot see user A's documents or subscription.
3. Anyone can read any user's documents
Where: app/api/documents/[id]/route.js:6. Critical.
GET /api/documents/[id] loads a document by id with the service role client and never checks who is asking. The ids are bigserial numbers, so they can be counted 1, 2, 3. Hiding a button is not access control, and a login is not an ownership check. Change the number in the URL and you are reading someone else's resume.
Documents are readable by anyone. Fix ownership checks:
1. In app/api/documents/[id]/route.js, in GET and PATCH, get the logged-in user from a Supabase server client built from the request cookies (@supabase/ssr createServerClient, auth.getUser()). Return 401 if not logged in. Run the query with that user's client (not supabaseAdmin) and filter by both .eq('id', params.id) and .eq('owner_id', user.id). Return 404 when nothing matches.
2. In app/api/search/route.js, require a logged-in user and add "AND owner_id = $2" with the user id as a parameter.
Do not change unrelated code. Verify with two test users: user B must get 404 for user A's document id and must not see A's documents in search.
4. An OpenAI key hardcoded in browser code
Where: app/dashboard/page.jsx:7. Critical.
The dashboard is a client component with the key in a constant, and it calls OpenAI straight from the browser for a "Quick preview" button. Anyone can read the key from your site's JavaScript and run up charges on your account.
Remove the OpenAI key from app/dashboard/page.jsx. Delete the OPENAI_KEY constant and the direct fetch to api.openai.com. Make quickPreview() call our own POST /api/generate route instead, sending only the text. The server route reads OPENAI_API_KEY from the server environment. Then search the whole repo for "sk-proj-" and "api.openai.com" and make sure they appear only in server code under app/api/. Do not change unrelated code. After the change, remind me to revoke the old key in the OpenAI dashboard and create a new one, because the old key has been public.
5. The AI endpoint has no login and no rate limit
Where: app/api/generate/route.js:3. High.
It calls gpt-4o with max_tokens: 4000 for anyone, with no login, no rate limit and no quota. It is not only login endpoints that need limits. Any route that calls a paid API, sends email or accepts uploads can be hit by a script in a loop, and the damage is your bill.
Protect app/api/generate/route.js: 1) Require a logged-in user via a Supabase server client from the request cookies; return 401 otherwise. 2) Install @upstash/ratelimit and @upstash/redis and limit each user to 10 requests per minute and each IP to 20 per minute; return 429 with a Retry-After header when exceeded. 3) Add a daily quota: free users 3 generations per day, pro users 100, counted in a new usage table. 4) Lower max_tokens to 1500. Apply the same login check and rate limit to app/api/import-url/route.js and app/api/upload/route.js. Do not change unrelated code. Also remind me to set a monthly spend limit in the OpenAI dashboard. Verify: call /api/generate 15 times in a minute locally and confirm 429 responses after the limit.
Payments
6. The Stripe webhook accepts fake events
Where: app/api/webhooks/stripe/route.js:4. Critical.
The webhook reads request.json() and trusts it. It never verifies the Stripe-Signature header, then upgrades whichever profile id is in client_reference_id to pro. Anyone can send one fake "checkout completed" request and give any account the paid plan for free.
Secure app/api/webhooks/stripe/route.js:
1. Read the raw body with `const body = await request.text()` (not request.json()).
2. Verify it with `stripe.webhooks.constructEvent(body, request.headers.get('stripe-signature'), process.env.STRIPE_WEBHOOK_SECRET)`. On error return 400 and do nothing else.
3. Add STRIPE_WEBHOOK_SECRET to .env.local (empty value for now) and to .env.example.
4. Make the handler idempotent: store processed event ids in a new table stripe_events (id text primary key) and skip events already seen.
5. Find the user from the Checkout Session's customer or metadata that the server set when creating the session, not from anything the browser sent.
Do not change unrelated code. Verify with the Stripe CLI: `stripe listen --forward-to localhost:3000/api/webhooks/stripe` then `stripe trigger checkout.session.completed` must succeed, and a curl POST with a made-up JSON body must get 400.
7. Checkout trusts the price and user id from the browser
Where: app/api/checkout/route.js:5. High.
/api/checkout takes priceInCents and userId from the request body and uses them as the amount and the account to upgrade. A buyer can edit the request to pay the minimum Stripe allows, or pay for someone else's account. The same finding noted there was no refund policy anywhere and no support contact. Stripe expects both to be visible on your site, and they are what a confused customer looks for before they email you or dispute the charge with their bank.
Stripe says the card industry treats dispute activity above 0.75% as excessive, and a sudden spike can trigger monitoring earlier. Under Visa's VAMP, a dispute-plus-fraud ratio of 1.5% (US, EU, Canada, AP, LAC) is Excessive, and Visa may fine merchants above that. For a small shop, a few disputes is a large percentage.
Fix app/api/checkout/route.js: 1) Accept only { plan } from the body. Map plan on the server to a Stripe Price id from an env var (STRIPE_PRICE_PRO) and pass line_items: [{ price: process.env.STRIPE_PRICE_PRO, quantity: 1 }]. Remove price_data and unit_amount. 2) Get the user from a Supabase server client built from the request cookies (401 if none) and set client_reference_id and metadata.user_id from that user, never from the body. 3) In app/pricing/page.jsx send only { plan: 'pro' }. 4) Add an app/refunds/page.jsx that states the refund window and how to ask, and link it in the footer and under the checkout button. 5) Add a support email link (mailto) in the footer and on app/account/page.jsx; ask me for the address. Do not change unrelated code. Verify: send a checkout request with priceInCents: 1 and confirm Stripe Checkout still shows the real price.
8. No way to cancel, no renewal terms on the paywall
Where: app/account/page.jsx:6. High.
Checkout creates a monthly subscription with a 7-day trial, but the pricing page shows no price, no billing interval and no "renews automatically" text. The account page says "Contact support" to cancel, with no contact and no cancel button. Signing up takes one click and cancelling needs a contact that does not exist. That is how unhappy users become chargebacks.
It is a legal question too. California's amended Automatic Renewal Law (AB 2863, effective 1 July 2025) requires express consent to auto-renewal, an easy online cancellation path, and annual reminders. Other states have their own laws. The FTC's 2024 click-to-cancel amendments were vacated by the 8th Circuit on 8 July 2025 on procedural grounds, and the FTC has since reopened the topic, but the older Negative Option Rule still applies.
Add self-serve cancellation and renewal terms. 1) Create app/api/billing-portal/route.js that requires a logged-in user, looks up their Stripe customer id, and returns the url of stripe.billingPortal.sessions.create({ customer, return_url: origin + '/account' }). 2) On app/account/page.jsx replace the "Contact support" text with a "Manage or cancel subscription" button that opens that url. 3) On app/pricing/page.jsx, next to the trial button, show the real price, the billing interval, that it renews automatically until cancelled, and how to cancel. 4) Remind me to enable the customer portal and trial-ending reminder emails in the Stripe dashboard. Do not change unrelated code. Verify: subscribe with a Stripe test card and cancel from the account page in two clicks.
Legal
9. Google Analytics and Meta Pixel load before consent
Where: app/layout.jsx:12. High.
The root layout loads Google Tag Manager and the Meta Pixel on every page, and there is no cookie banner or consent check anywhere. A banner on screen does not mean the script waited for the click. If it loads on page load, the banner is decoration.
California Penal Code 637.2 lets a person injured by a violation sue for the greater of $5,000 per violation or three times actual damages, and actual damages need not be shown. Plaintiffs have sued sites over trackers under this law. Courts differ on how far it reaches, so treat this as a reason to check, not a verdict.
Gate tracking behind consent. 1) Create components/ConsentBanner.jsx (client component) with "Accept" and "Reject" buttons of equal size and prominence, a link to /privacy, and the choice saved in a first-party cookie for 12 months. 2) Move the Google tag and Meta Pixel scripts out of app/layout.jsx into components/TrackingScripts.jsx, rendered only after the visitor clicked Accept. 3) Before any Google tag loads, set gtag('consent', 'default', { analytics_storage: 'denied', ad_storage: 'denied', ad_user_data: 'denied', ad_personalization: 'denied' }). 4) Add a "Cookie settings" link in the footer that reopens the banner. Do not change unrelated code. Verify: in a fresh private window, confirm in DevTools Network that no request goes to googletagmanager.com or connect.facebook.net until you click Accept.
10. No privacy policy
Where: whole app. High. A related finding, "AI use is not disclosed", is medium.
The only thing in the footer was "Made with love." The app collects emails and work history and sends them to Supabase, Stripe, OpenAI, Google Analytics and Meta. Privacy laws, Stripe, app stores and Google sign-in expect a policy that says what you collect and who you share it with.
If you serve users in the EU and your app generates content with AI, there is a second page to think about. The AI Act's Article 50 transparency obligations have applied since 2 August 2026. Providers of AI systems that talk directly to people must make clear it is an AI, unless obvious. Providers of generative systems must mark outputs machine-readably. Breaches of the AI Act transparency duties can be fined up to EUR 15 million or 3% of worldwide annual turnover, whichever is higher. For SMEs and startups the cap is the lower of the two.
Create app/privacy/page.jsx with a privacy policy for ResumeFlash, marked at the top "Starter template, not legal advice. Have it reviewed." Cover: what we collect (email, name, resumes and work history, payment status, analytics and pixel data), why, who receives it (Supabase for hosting the database, Stripe for payments, OpenAI for generating resumes, Google Analytics, Meta), how long we keep it, how users can export or delete their data and account, cookies and how to withdraw consent, and a contact email (ask me for it). Link it in the footer in app/layout.jsx and on the sign-up screen. Add a clear "This app uses AI and it can make mistakes" notice where users see generated output. Do not change unrelated code.
What the audit did not cover
The report has a "Needs a manual check" list, because code cannot show everything. In this demo: whether RLS is on in the live Supabase project, bucket privacy in Supabase Storage, the Stripe webhook signing secret and customer portal settings, a spend limit in the OpenAI dashboard, and any firewall or rate-limit rules at the host. A clean report is not the same as a safe app.
How to run it yourself
In Claude Code, install it as a plugin:
/plugin marketplace add Lt23live/launchvet
/plugin install launchvet@launchvet
Open Claude Code in your project and run /launchvet:launchvet, or just ask "Run LaunchVet on this app." (If you copy the skill folder into ~/.claude/skills/ instead of installing the plugin, the command is /launchvet.) You need Node.js 18 or newer for the scanner.
You get launchvet-report.md in your project, ranked worst first. Every finding has the file, the reason and a fix prompt. Work from the top, paste a prompt, check the diff, run it again to confirm the finding is gone. LaunchVet never edits your code.
LaunchVet runs inside your own Claude Code session. There is no LaunchVet server, no signup, and nothing is uploaded to us. The scanner script makes no network calls. Claude Code itself still reads your files and talks to Anthropic the way it does for any task, so if that is a problem for your repo, this does not change it.
Free and Pro, in two lines: the free core is MIT licensed on GitHub with 26 checks. Pro is a one-time $19 on Gumroad ($12 until 11 Nov 2026, 11:59 pm PT, link below) and adds more checks, payments and Stripe readiness, an app store module, legal page starter templates and a CLAUDE.md security rulebook, and you do not need it to fix what the free core finds.
Repo: https://github.com/Lt23live/launchvet
Full demo report: https://github.com/Lt23live/launchvet/blob/main/examples/broken-app/launchvet-report.md
Site: https://lt23live.github.io/launchvet/
Pro on Gumroad ($12 with the launch code until 11 Nov 2026, 11:59 pm PT, then $19): https://greystatedevelopment.gumroad.com/l/launchvet/LAUNCH12
LaunchVet is an automated checklist, not a security guarantee or legal advice. AI is used in this product and can make mistakes.
Which check do you think is missing? Tell me in the comments.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.