I've been running VibeScan — my security audit for AI-generated SaaS — against public Lovable / Bolt / v0 apps all week. I wrote up the 5 patterns I keep finding yesterday.
This post is about the single most common one: Supabase Edge Functions (or Vercel Functions, or Cloudflare Workers — the pattern is identical) deployed without any auth verification.
Hit rate in my corpus: 8 out of 9.
This is not "the app is missing MFA". It's not "the password policy is weak". It is: anyone on the internet who finds or guesses your function's URL can invoke it as if they were a logged-in user. With your service-role permissions. For as long as they want.
Here's why it happens, what the blast radius looks like, and the ~20 lines of code that fix it.
Quick refresher: what Edge Functions actually are
If you've only touched client-side React in Lovable or Bolt, you may not have thought much about what happens when you call supabase.functions.invoke('process-payment', ...).
What happens is: Supabase proxies the call to a public HTTPS endpoint — something like https://<project-ref>.supabase.co/functions/v1/process-payment. That URL is reachable from anywhere. Your React app hits it with a fetch() behind the scenes. The function executes in a Deno runtime, with access to your service-role key and your whole Postgres database.
Same shape for Vercel (/api/whatever.ts), Cloudflare Workers, Netlify Functions. Public URL → server-side code → your secrets.
The only thing standing between an attacker and that URL is whatever auth check your function code performs before doing work.
That's what's missing in 8 out of 9 AI-scaffolded apps.
The failure mode
Here's the kind of code I keep finding. This is anonymized but structurally identical to what I saw in a healthcare patient-intake app, a payments-adjacent app, and an AI-generation app all this week:
// supabase/functions/process-payment/index.ts
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import { createClient } from "npm:@supabase/supabase-js@2";
serve(async (req) => {
const { user_id, amount, card_token } = await req.json();
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, // full DB access
);
// ... charges card via Stripe, inserts into orders, etc.
await supabase.from("orders").insert({ user_id, amount });
return new Response(JSON.stringify({ ok: true }));
});
Read the first line carefully: const { user_id, amount } = await req.json().
The user_id is coming from the request body. Not from an authenticated session. Not verified against anything. I — a random person on the internet with curl — can POST to this endpoint with any user_id I want and any amount I want, and the function will insert an order for that user at that amount, using the service-role key which bypasses RLS entirely.
I can insert fake orders in other users' names. I can set amount to $0 and run the function a million times and exhaust your Supabase egress quota. I can set amount to $1,000,000 and corrupt your accounting. If the function calls Stripe — I can spam Stripe until your account is flagged.
The function runs. No auth. Full access.
Why AI scaffolding misses it
Three reasons, all fixable:
Supabase's function template.
supabase functions newscaffolds a bare handler. It doesn't include an auth block. The "Hello World" in the docs doesn't either. If an AI model was trained on the template or the Hello World, it will copy that pattern — and the pattern has no auth.The Row-Level Security illusion. If the developer has RLS policies on tables, they (reasonably) think "the database handles auth". It does — but only when you query it with the anonymous key and the user's JWT. The moment you use the service-role key (which Edge Functions do by default, because you often need to do admin work), RLS is bypassed. The DB no longer checks who the caller is. The function has to.
Client-side auth looks enough. The React app requires login before it even shows the "pay" button. So in the developer's head, the flow is "logged-in user → click button → call function → done". They forget that the function's URL is a public artifact and anyone with it can skip the React app entirely.
The fix — about 20 lines
You need two things in every single Edge Function that isn't a public webhook:
-
Verify the caller's JWT — extract it from the
Authorizationheader, ask Supabase to validate it, get back auserobject (or reject). -
Use that
user.idas the authoritative source for any identity the function does work on — not the request body.
Here's the minimal pattern:
// supabase/functions/process-payment/index.ts
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import { createClient } from "npm:@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
// 1. Verify the caller's JWT. The Authorization header is forwarded by
// supabase.functions.invoke() automatically; we just need to validate it.
const authHeader = req.headers.get("Authorization");
if (!authHeader) return new Response("missing auth", { status: 401, headers: corsHeaders });
const supabaseUserClient = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_ANON_KEY")!, // anon key, with user's JWT attached
{ global: { headers: { Authorization: authHeader } } },
);
const { data: { user }, error } = await supabaseUserClient.auth.getUser();
if (error || !user) return new Response("invalid token", { status: 401, headers: corsHeaders });
// 2. Now you can trust user.id. Do NOT trust request-body user_id.
const { amount, card_token } = await req.json(); // deliberately ignoring any user_id in body
const userId = user.id;
// 3. For the actual write, decide: admin work (service role) or user-scoped (anon + JWT)?
// Inserting an order FOR the verified user is fine either way, but service-role
// is the safe choice if you also need to write to admin-only tables.
const supabaseAdmin = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
await supabaseAdmin.from("orders").insert({ user_id: userId, amount });
return new Response(JSON.stringify({ ok: true }), { headers: { ...corsHeaders, "content-type": "application/json" } });
});
That's it. About 20 lines of added logic. supabase.functions.invoke on the client automatically forwards the user's Authorization header, so you don't have to change your React code at all.
Key things to notice:
- We use two clients: one with the anon key + user's JWT for identity verification (
getUser()call), another with the service-role key for the actual DB write. - We ignore any
user_idin the request body. The authoritative identity isuser.idfrom the verified token. - The function returns 401 for missing or invalid auth. No information leakage about whether a user exists.
Testing the fix
Before you ship, verify the fix actually works with three curl calls:
# 1. No auth header → should return 401
curl -X POST 'https://<project-ref>.supabase.co/functions/v1/process-payment' \
-H "Content-Type: application/json" \
-d '{"amount": 50}'
# → 401 "missing auth"
# 2. Bogus token → should return 401
curl -X POST 'https://<project-ref>.supabase.co/functions/v1/process-payment' \
-H "Authorization: Bearer not-a-real-jwt" \
-H "Content-Type: application/json" \
-d '{"amount": 50}'
# → 401 "invalid token"
# 3. Valid token (get one by logging in on your React app and copying the access_token from localStorage)
curl -X POST 'https://<project-ref>.supabase.co/functions/v1/process-payment' \
-H "Authorization: Bearer <real-jwt>" \
-H "Content-Type: application/json" \
-d '{"amount": 50}'
# → 200 {"ok": true}
Do this before deploying. If test 1 or 2 return 200, the auth block is misplaced and the function is still open.
Bonus: the other things you want on public functions
Once auth is in place, two more cheap upgrades are worth doing the same afternoon:
Rate limiting. Even with auth, a malicious (or simply buggy) authenticated user can spam your function. A 5-line Upstash Redis check:
import { Ratelimit } from "npm:@upstash/ratelimit";
import { Redis } from "npm:@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(20, "60 s"), // 20 requests per minute per user
});
const { success } = await ratelimit.limit(userId);
if (!success) return new Response("rate limit", { status: 429 });
Upstash's free tier is plenty for most side projects.
Cron-only functions. If a function is meant to run only from a scheduled job (e.g. daily-cleanup), don't require JWT auth — require a specific secret header instead, so the cron job can call it but random authenticated users cannot:
const cronSecret = req.headers.get("X-Cron-Secret");
if (cronSecret !== Deno.env.get("CRON_SECRET")) {
return new Response("forbidden", { status: 403 });
}
Set X-Cron-Secret on your Supabase cron job, and you have a scoped-access function that users can't invoke at all.
The meta point
Every one of these fixes is cheap. Adding JWT verification is 20 lines. Rate limiting is 5 more. A cron-secret check is 3. Total: under 30 lines of code per function.
The problem isn't the difficulty. It's the invisibility. The function works fine without these blocks — your React app happily calls it, orders get inserted, payments go through. Nothing looks broken until someone curls your endpoint and discovers they have superuser access.
AI scaffolding won't add these for you by default. If you're shipping on Lovable, Bolt, v0, or anything else that scaffolds Supabase + Edge Functions for you — every function you deploy needs this pattern manually added.
If you want your whole stack checked
This is one of 150+ patterns I scan for. If you have a Supabase + Vercel/Netlify AI-coded app and you'd rather I grep every single function + policy + endpoint for this class of issue, VibeScan is $49 one-time at systag.gumroad.com/l/vibescan. PDF delivered in ~10 minutes, severity-graded, every finding with the exact file, line, and copy-paste fix. 7-day refund if the report isn't useful.
Otherwise — the snippet above is the highest-impact thing you can add to a vibe-coded SaaS tonight. Harden every function, and you've removed the single most common attack surface in the AI-scaffolded stack.
— Michael
Top comments (0)