Yesterday I ran VibeScan — my security-audit tool for AI-generated SaaS — against 9 public apps built on Lovable / Bolt / v0 / Cursor. Different verticals: healthcare (patient records), finance (AI script-for-sale platform), productivity (logic/notes tools), crypto (CELO transfers), gym management, blockchain auditing.
Total: 145 findings, 9 critical, 75 high, 61 medium.
Five patterns showed up in basically every single one. Not "I saw them occasionally" — I saw them in 8-9 out of 9. If you're shipping an AI-coded SaaS right now, these are the ones to fix tonight, before anyone signs up.
1. RLS policies with USING (true) — "looks secure, isn't"
Hit rate: 8 / 9 codebases.
Every single app I scanned had Supabase RLS enabled on its core tables. That looks fine — RLS is on. Until you read the policies:
CREATE POLICY "authenticated users can read"
ON public.cases FOR SELECT TO authenticated
USING (true);
If cases stores per-user data and your app has open signup, this is a fancy way of saying: "anyone who creates a throwaway Gmail account reads every other user's records." I saw this on medical case files, on AI generation history, on gym-member workout logs. On one healthcare app, any signed-in user could read AND modify every patient record + physician note + uploaded file in the database.
Fix:
USING (user_id = auth.uid())
For UPDATE policies, add WITH CHECK (user_id = auth.uid()) too, or users can flip the user_id column during their update and steal other people's rows.
I wrote a full tutorial on getting RLS right earlier today — it's the single highest-impact thing you can fix this week.
2. Unauthenticated edge functions — "verify_jwt = false"
Hit rate: 9 / 9 codebases.
Every single app had at least one edge function with verify_jwt = false in supabase/config.toml. Sometimes a half-dozen. Most of them were AI endpoints (calls to OpenAI, Gemini, Claude) or payment webhooks.
[functions.payment-webhook]
verify_jwt = false
Payment webhooks need this — Stripe has to be able to call you without a user JWT. But the fix is then to verify the signature inside the function:
const event = stripe.webhooks.constructEvent(
body,
req.headers.get("stripe-signature"),
Deno.env.get("STRIPE_WEBHOOK_SECRET"),
);
On 7 of the 9 apps, that verification was missing or replaced by a shared secret sent in a custom header (which is worse — the secret travels over the wire on every request and leaks into logs).
On AI endpoints with verify_jwt = false, the function is world-callable. Anyone can hit it in a while(true) loop and drain your OpenAI credits in an hour. On one app the LLM endpoint would happily accept 2MB request bodies. Attacker math: $X credits per request × unlimited requests → bye bye balance.
Fix: turn verify_jwt = true back on for anything that isn't a payment webhook, then verify auth inside the function. For the webhook exceptions, verify the provider signature.
3. No rate limit on AI / generation / contact / signup endpoints
Hit rate: 9 / 9 codebases.
None of the 9 apps had rate limiting in front of their expensive endpoints. Not on AI generation. Not on contact forms. Not on signup. Not on password reset.
This matters in two flavors:
- Credit drain: the AI-generation endpoint on one app would gladly take a 50,000-character prompt and call GPT-4 on it. No cap on requests per user, no cap on input size. A single compromised account runs up a $500 bill overnight.
-
Signup / spam flood: one app had open signup with no captcha and no rate limit. Combined with the
USING (true)RLS issue, that means an attacker can spin up 10,000 fake accounts and each one gets read access to all the real users' data.
Fix (Supabase edge functions):
import { Ratelimit } from "@upstash/ratelimit";
const ratelimit = new Ratelimit({
redis: ...,
limiter: Ratelimit.slidingWindow(10, "1 m"),
});
const { success } = await ratelimit.limit(userId ?? ip);
if (!success) return new Response("rate limited", { status: 429 });
For signup, Supabase has a native rate-limit toggle in Auth → Policies. Turn it on. Also enable Turnstile / hCaptcha — supabase-js supports it natively now on signUp.
4. CORS wide open — Access-Control-Allow-Origin: *
Hit rate: 9 / 9 codebases.
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
Every AI coding assistant ships this snippet as the default. On its own, on a public static endpoint, fine. But when you have:
-
CORS: *+ - An authenticated edge function that accepts the
Authorizationheader + - A logged-in user visiting a malicious page —
…then the malicious page can trigger authenticated calls on behalf of the user. They browse to funny-cat-memes.xyz; in the background their browser fires a DM delete, a subscription upgrade, an AI-credit burn against your app.
Fix: replace * with your app's actual origin (or an allow-list):
const ALLOWED = new Set([
"https://myapp.com",
"https://myapp.vercel.app",
]);
const origin = req.headers.get("Origin") ?? "";
const corsHeaders = {
"Access-Control-Allow-Origin": ALLOWED.has(origin) ? origin : "null",
...
};
5. Race conditions on balance / credits / usage counters
Hit rate: 6 / 9 codebases (but 100% of codebases that had any billing / free-tier concept).
Classic pattern:
const { data: usage } = await supabase
.from("usage_tracking")
.select("generation_count")
.eq("user_id", userId)
.single();
if (usage.generation_count >= 5) {
return new Response("quota exceeded", { status: 429 });
}
// ... do the expensive AI call ...
await supabase
.from("usage_tracking")
.update({ generation_count: usage.generation_count + 1 })
.eq("user_id", userId);
A user fires 10 requests simultaneously. All 10 read generation_count = 0. All 10 pass the < 5 check. All 10 increment. The user got 10 free AI calls on your dime.
Same pattern shows up on:
- Inventory decrements (oversell — sold 10 units, only had 5)
- Balance transfers (fire 5 parallel withdrawals of $100 from a $100 account)
- Discount code usage (single-use code used 20 times)
Fix: do the check-and-increment atomically in one SQL statement:
UPDATE usage_tracking
SET generation_count = generation_count + 1
WHERE user_id = $1 AND generation_count < 5
RETURNING generation_count;
If the row isn't updated (no rows returned), the user hit their cap. Either use a Postgres function like this called via rpc(), or an UPDATE ... WHERE guard in the edge function. Don't do the check and the update as separate operations.
Why this matters
Each of these issues is not a theoretical risk. On every codebase I audited, at least one of these was exploitable by anyone on the internet today — no special tools, no privileged access. A curious user with a browser console can:
- Read all other users' records (pattern 1)
- Upgrade their own account to a paid tier without paying (pattern 2)
- Drain your AI credits in an afternoon (pattern 3)
- Get other users to unknowingly perform actions (pattern 4)
- Bypass every usage limit you've tried to enforce (pattern 5)
If you're shipping AI-coded apps, these five are the first pass. They take 2-4 hours to fix if you know what you're looking for.
If you want the list for your codebase specifically — which findings, which files, which lines, with copy-paste fixes for each — that's what VibeScan does. $49, runs on a public GitHub repo, PDF report back in the hour. Most first-time scans on a Lovable/Bolt/v0/Cursor app come back with 1 critical + 5-10 high severity findings, roughly half of which are the patterns above.
Either way: if one of these five rang a bell, go check that code before you close this tab.
Top comments (0)