Fetching latest headlines…
The 12 Security Issues I Keep Finding in Vibe-Coded Apps (Lovable, Bolt, v0)
NORTH AMERICA
πŸ‡ΊπŸ‡Έ United Statesβ€’April 19, 2026

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

0 views0 likes0 comments
Originally published byDev.to

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

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"));

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);

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.

3. API keys prefixed with 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.

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 });

5. Profile row created from the client

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

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);

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;

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');

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])

8. Hardcoded 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.

9. Weak password policy

<input type="password" minLength={6} />

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",
};

* 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

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());

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);
}

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.

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.

Comments (0)

Sign in to join the discussion

Be the first to comment!