DEV Community

SystAgProject
SystAgProject

Posted on

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

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.


1. Payment webhook has verify_jwt = false and no signature check

What you'll find in your repo

# supabase/config.toml
[functions.payment-webhook]
verify_jwt = false
Enter fullscreen mode Exit fullscreen mode

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"));
Enter fullscreen mode Exit fullscreen mode

2. RLS policies using USING (true)

What you'll find

CREATE POLICY "authenticated users can read"
  ON public.cases FOR SELECT TO authenticated
  USING (true);
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

Then make sure you actually set user_id = auth.uid() on INSERT with a WITH CHECK clause.


3. API keys prefixed with VITE_ — shipped to every browser

// src/components/ResumeUpload.tsx
const key = import.meta.env.VITE_GEMINI_API_KEY;
Enter fullscreen mode Exit fullscreen mode

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.


4. No rate limit on the expensive LLM endpoint

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 });
Enter fullscreen mode Exit fullscreen mode

5. Profile row created from the client

// After signUp({ email, password })
await supabase.from("profiles").insert({ id: user.id, name, role: "user" });
Enter fullscreen mode Exit fullscreen mode

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 ...
Enter fullscreen mode Exit fullscreen mode

And restrict the profiles.role column from client UPDATEs.


6. Subscription tier writable from the client

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);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

7. Uploaded files readable by any logged-in user

CREATE POLICY "read uploads"
  ON storage.objects FOR SELECT TO authenticated
  USING (bucket_id = 'case-files');
Enter fullscreen mode Exit fullscreen mode

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])
Enter fullscreen mode Exit fullscreen mode

8. Hardcoded SUPABASE_URL + anon key as fallbacks

const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL ?? "https://abc123.supabase.co";
Enter fullscreen mode Exit fullscreen mode

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.


9. Weak password policy

<input type="password" minLength={6} />
Enter fullscreen mode Exit fullscreen mode

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".


10. CORS wide open on server actions

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "POST, OPTIONS",
};
Enter fullscreen mode Exit fullscreen mode

* 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.


11. No input validation on write endpoints

const { topic, keyMessage, audience } = await req.json();
// straight into the LLM prompt
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

12. Credits / balance updates that aren't atomic

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

If the returned row is empty, reject the request.


How to check your own repo in 5 minutes

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.

Top comments (0)