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 = falseinsupabase/config.toml -
USING (true)in*.sql -
VITE_.*_KEY/NEXT_PUBLIC_.*_KEY/REACT_APP_.*_KEYin source -
minLength={6}in auth forms -
Access-Control-Allow-Origin: *in server functions -
corsHeaderswithout 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)