TL;DR: Every Supabase edge function is a public URL. The verify_jwt flag is half an auth check: it proves a caller is logged in, not that the caller may act. Real edge function auth has three layers. Check what the user may do inside the function. Keep the service_role key server-side only. Lock CORS to your own domain. AI editors like Lovable, Bolt, Cursor, and v0 skip all three by default. Here is the checklist, and a free scan that tests your live app in about 30 seconds.
An edge function feels private. It is not. It lives at a fixed public address, and anyone on the internet can call it. Your frontend is just one caller among many. AI editors scaffold these functions as if only your app will ever knock. That one wrong idea drives every gap on this page.
An edge function is a public URL. Treat it like one.
Every function you deploy gets an address like https://your-project.supabase.co/functions/v1/send-invite. There is no hidden network in front of it. If your frontend can reach it, so can a stranger with curl. So can a script that found the URL in your page source.
AI editors write these functions to serve one caller: your own app. They name them "internal" in comments. The name does nothing. The URL is public, and the code inside is the only gate. The whole question of Supabase edge functions auth comes down to what that code checks before it acts.
The verify_jwt gap: logged in is not allowed
Supabase gives each function a verify_jwt setting. When it is on, a request must carry a valid token in the Authorization header. AI scaffolds lean on that flag and stop. It looks like auth. It is half of auth.
The flag proves one thing: the caller is a logged-in user. Any logged-in user. It does not prove the caller owns the row they are about to change. It does not prove they hold the admin role the action needs. We have reviewed functions where any fresh signup could fire an admin-only action. The token was valid. The check on what that token could do never existed.
A real check does three things inside the function:
- Read the user from the token. Pull the user ID from the verified claims, not from the request body. The body is attacker input.
- Check the role or ownership. Does this user own the record? Does this action need an admin flag? Ask before you act.
- Fail closed. No token, wrong role, expired claims — return 401 or 403 and stop. Do not fall through to the happy path.
Where the token lives on the client matters too. Many AI builds park it in localStorage, where any injected script can read it. We cover that trade-off in storing JWTs in localStorage.
The service_role key stays on the server. No exceptions.
The service_role key bypasses Row Level Security. All of it. Every table, every row. It exists so a trusted server process can do admin work. That power is the whole point — and the whole risk.
AI editors copy this key to wherever the code stops erroring. Sometimes that is frontend code, which ships it to every visitor. Sometimes it is hardcoded in the function file, which puts it in your repo. And often it lands in a function that has no auth check in front of it at all.
A public function that holds the service_role key with no auth gate is an open database. Anyone with the URL has admin access. One curl command pulls every row of every table.
The fix is short. Store the key with supabase secrets set, read it from the environment inside the function, and put a permission check ahead of every use. If you are not sure which key does what, read anon key vs service role key before you ship.
CORS: the quiet hole in the scaffold
CORS headers tell browsers which sites may call your function. The AI-generated template sets Access-Control-Allow-Origin: * and moves on. The app works, the demo passes, and nobody looks at that line again.
The wildcard does not break anything. That is the trap. It strips a defense the browser gives you for free. With it gone, a hostile page can call your function from someone else's tab. Pair it with a weak JWT check and you get a two-part failure: any origin may call the function, and the function trusts anyone with a token. Two half-checks add up to zero.
Lock the header to your real domain. Handle the OPTIONS preflight and send the same strict headers on error replies too. Many scaffolds only set them on the happy path. Our guide to CORS misconfiguration in vibe-coded apps walks through the exact header set.
The five scaffold mistakes we keep finding
- No permission check on "internal" functions. The comment says internal. The URL is public.
- The
service_rolekey hardcoded in the function file. It belongs in a server-side secret. - Wildcard CORS left from the template. Works in the demo. Open in production.
- No rate limits on anything. A single script can hammer an auth endpoint all night. Our k6 engine reports this word for word as
"No rate limiting detected under concurrent load."(k6Scanner.js:170). Test one endpoint yourself with the free API rate limit checker. - RLS policies that grant everything. A policy of
USING (true)turns RLS on in name only. Everyone still gets in.
RLS is your second layer. Check that it is real.
Row Level Security is meant to hold even when a function check slips. But it only helps if it is set up right. Two questions decide it: is RLS on for every table, and are the policies owner-scoped? A lot of vibe-coded builds fail at least one. CVE-2025-48757 showed what that looks like at scale. 170 or more AI-built apps were left readable. RLS was off or too loose. Same stack, same default, same failure.
The two layers interact. A function that runs with the service_role key skips RLS by design. So a weak function check plus strong RLS still leaks. And a strong function check plus USING (true) leaks the other way. You need both layers to hold at once. Our RLS policies guide covers writing owner-scoped rules, and the free RLS checker probes your live app for the gap.
The three layers, side by side
| Layer | What it actually does | The AI default | The fix |
|---|---|---|---|
verify_jwt | Confirms the token is valid and not expired | On, and treated as full auth | Keep it on, then check role or ownership inside the function |
service_role key | Bypasses RLS for trusted server work | Hardcoded, or used with no gate in front | Server-side secret plus a permission check before every use |
| CORS | Tells browsers which origins may call | Access-Control-Allow-Origin: * | Lock to your domain; cover preflight and error paths |
| RLS | Row-level gate at the database | Off, or USING (true) | On for every table, owner-scoped policies |
Why this keeps happening
The tools are not careless. They aim at the wrong target. Lovable, Bolt, Cursor, and v0 chase a feature that runs, not one that resists abuse. The outside numbers say how often that shows: Symbiotic Security scanned 1,072 AI-built apps and found 98% had at least one flaw, with 16% carrying critical issues. CMU's SusVibes study put at least one security flaw in 61% of AI-written code. Across 100 apps we scanned ourselves, the average Launch Readiness Score was 42/100.
None of that is a reason to drop the tools. It is a reason to run a second pass on the auth layer. That is the one place where "it works" and "it is safe" look the same from the outside.
One legal note before launch
If your edge functions decide who can read personal data, a weak check is not just a bug. Under GDPR it is a data leak waiting to happen. And if your app has an AI-facing feature and EU users, more rules kick in. Article 50 of the EU AI Act applies from August 2, 2026. Its transparency rules carry fines up to €15 million or 3% of global turnover. Our GDPR guide for vibe-coded apps covers the data side, and the Compliance Wing checks both.
The edge function auth checklist
- List every deployed function. Each one is a public URL.
- Keep
verify_jwton. Then add a role or ownership check inside each function. - Move the
service_rolekey tosupabase secrets set. Grep your repo and bundle for it. - Replace wildcard CORS with your real domain. Cover preflight and error responses.
- Rate-limit auth, reset, and invite endpoints.
- Turn on RLS for every table. Owner-scoped policies. No
USING (true). - Re-test from outside after every fix. Fixes interact.
What checking this costs
The free scan is $0 and takes about 30 seconds. You give it your live URL. You get a Launch Readiness Score out of 100 across security, reliability, performance, and monitoring. The full Launch Readiness Audit is $499 one time — a branded report with every finding, a ranked fix plan with time estimates, and a benchmark against 200+ audited apps. Want the fixes done for you? Done-for-you setup starts at $1,999 with a named CTO. The full picture of what we probe on this stack is on the Supabase security audit page.
FAQ
Does Supabase verify JWTs for me?
Only in part. The verify_jwt flag confirms a request carries a valid token. It does not confirm the user may touch the row or resource at hand. You still need a permission check inside the function.
Is it safe to use the service role key in an edge function?
Only if the key lives in a server-side secret and the function checks who is calling first. The service role key bypasses Row Level Security. A public function that holds it with no auth gate is an open database.
What is the most common CORS mistake in edge functions?
Leaving the wildcard Access-Control-Allow-Origin: * from the AI scaffold. The app still works, so nobody looks twice. But it removes a browser defense that matters once your JWT check is weak.
Do I still need auth checks if RLS is on?
Yes. RLS is the second layer, not a replacement. A function that runs with the service role key skips RLS entirely. And a policy of USING (true) grants access to everyone anyway. Check both layers.
How do I test my edge functions without code access?
Call them the way a stranger would. Every edge function is a public URL. Our free scan tests your live app from the outside, the same way an attacker starts. No install, no repo access, about 30 seconds.
What does a Supabase edge functions audit cost?
The scan is $0 and gives you a Launch Readiness Score out of 100. The full Launch Readiness Audit is $499 one time: a branded report, a ranked fix plan with time estimates, and a benchmark against 200+ audited apps.
The pattern here matches every stack in our vibe coding security guide: the code works, the defaults do not. Your edge functions run. Whether they check who is calling is a separate question — and it takes 30 seconds to start answering it.
Is your auth layer actually finished?
Scan your live app. No code access. A Launch Readiness Score out of 100, in about 30 seconds.
Run the free scan — $0