AI News Hub Logo

AI News Hub

The 12 Security Issues I Keep Finding in Vibe-Coded Apps (Lovable, Bolt, v0)

DEV Community
SystAgProject

Over the last few weeks I've been running VibeScan — a security audit tool for AI-generated codebases — against a small set of public Lovable / Bolt / v0 / Cursor apps. Same dozen issues keep surfacing. If you're shipping a vibe-coded SaaS, run through this list before launch. It'll take you 30 minutes and save you from the most common self-own patterns. verify_jwt = false and no signature check What you'll find in your repo # supabase/config.toml [functions.payment-webhook] verify_jwt = false And inside the function, no stripe.webhooks.constructEvent(...) before trusting the event body. Why it matters. The endpoint is world-reachable. Anyone can curl it with a fake "type": "checkout.session.completed" body and flip a row in your profiles table. Free Pro tier for everyone on the internet. Fix (one line-change + one env var) const event = stripe.webhooks.constructEvent(body, signatureHeader, Deno.env.get("STRIPE_WEBHOOK_SECRET")); USING (true) What you'll find CREATE POLICY "authenticated users can read" ON public.cases FOR SELECT TO authenticated USING (true); If cases is "any record" — not "my records" — then any signed-in user reads all the data. Open signup + USING (true) + RLS enabled = a fancy way to display your entire database to any visitor who clicks "Sign up". Fix: scope by ownership. USING (user_id = auth.uid()) Then make sure you actually set user_id = auth.uid() on INSERT with a WITH CHECK clause. VITE_ — shipped to every browser // src/components/ResumeUpload.tsx const key = import.meta.env.VITE_GEMINI_API_KEY; Anything with VITE_ / NEXT_PUBLIC_ / REACT_APP_ is in the client bundle. Open DevTools → Network tab → find any request with the key in Authorization → paste it into Postman. Fix: move the API call to a Supabase Edge Function (or Next.js server route) that holds the key server-side. The browser calls your endpoint; your endpoint calls the vendor. Your generate-something endpoint runs an Opus / GPT-4 call. It accepts an arbitrary-length prompt. There's no cap on requests per user. Someone writes a while(true) loop in the console. Your monthly AI bill is now $4k. Fix: two lines with Upstash. const { success } = await ratelimit.limit(userId); if (!success) return new Response("rate limited", { status: 429 }); // After signUp({ email, password }) await supabase.from("profiles").insert({ id: user.id, name, role: "user" }); The problem isn't the insert. It's that any signed-in user can do an UPDATE with role = "admin" if your RLS policy lets the user write to their own row and the role column isn't excluded. Fix: move profile creation to a Postgres trigger on auth.users: CREATE TRIGGER handle_new_user AFTER INSERT ON auth.users ... And restrict the profiles.role column from client UPDATEs. This is the evil cousin of #5. You have a profiles.subscription_tier column. Your RLS allows UPDATE FOR authenticated USING (user_id = auth.uid()). Any user opens console, runs: await supabase.from("profiles").update({ subscription_tier: "pro" }).eq("id", myId); Done. Lifetime Pro access. Fix: subscription_tier is a server-only column. Update it in a trigger that fires from your payment webhook, and revoke UPDATE on that column from the authenticated role: REVOKE UPDATE(subscription_tier) ON profiles FROM authenticated; CREATE POLICY "read uploads" ON storage.objects FOR SELECT TO authenticated USING (bucket_id = 'case-files'); Anyone who signs up can download every file in the bucket. Particularly painful when the bucket has resumes, medical records, or passport scans. Fix: encode the user ID in the path and check it in the policy. USING (bucket_id = 'case-files' AND auth.uid()::text = (storage.foldername(name))[1]) SUPABASE_URL + anon key as fallbacks const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL ?? "https://abc123.supabase.co"; The anon key is technically public (it's designed to be shipped to browsers). But hardcoding it means: You can't rotate without shipping a new build. You can't use the same codebase for staging / prod. If someone ever adds the service_role key by mistake under the same pattern, it's game over. Fix: throw new Error("missing env var") at build time if the var is missing. No fallback. Six characters is brute-forceable in under a second. Fix: minLength={10} on the input, and enforce a floor in Supabase Auth settings → Policies → Password requirements. Also turn on the "leaked password check". const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS", }; * is correct for static public endpoints. It's not correct for an endpoint that returns user-specific data or does a sensitive action on a cookie-authenticated session. Any site the victim visits can fetch() your API with their creds attached. Fix: echo the Origin header back only if it matches an allowlist. Or just hardcode your app's domain. const { topic, keyMessage, audience } = await req.json(); // straight into the LLM prompt No zod.parse(...). No length cap. Someone sends a 500 KB prompt. Your model call burns $3 and times out. Multiply by 10k loops. Fix: const schema = z.object({ topic: z.string().max(500), keyMessage: z.string().max(500), audience: z.string().max(100), }); const parsed = schema.parse(await req.json()); const { credits } = await supabase.from("users").select("credits").eq("id", userId).single(); if (credits > 0) { await doTheExpensiveThing(); await supabase.from("users").update({ credits: credits - 1 }).eq("id", userId); } Classic race. User fires two parallel requests, both read credits = 1, both proceed, both decrement. One free call. In the worst case, it's 50 parallel calls for one credit. Fix: atomic decrement in a single statement (or a Postgres function): UPDATE users SET credits = credits - 1 WHERE id = $1 AND credits > 0 RETURNING credits; If the returned row is empty, reject the request. Manual approach: grep the repo for these patterns. verify_jwt = false in supabase/config.toml USING (true) in *.sql VITE_.*_KEY / NEXT_PUBLIC_.*_KEY / REACT_APP_.*_KEY in source minLength={6} in auth forms Access-Control-Allow-Origin: * in server functions corsHeaders without an allowlist If you want a cleaner version of the above as a per-repo PDF with every finding graded and a copy-paste fix for each, that's what VibeScan is. It clones your repo, runs a multi-batch audit with Claude Opus 4.7, and spits out a severity-graded report. $49 one-time. Typical finding count for a 3-month-old vibe-coded app is 6-15 issues, 1-2 of them critical. If you want me to run it on your repo for free in exchange for feedback, reply to me on Twitter/X or send me the repo URL. Stay safe out there.