Edge function open without sign-in while using the service key
A server function holding the key that ignores every rule, with nothing checking who is calling it.
- Severity
- High
- Who can exploit it
- Anyone who finds the function's URL
- Found by
- PatchLabs Code: supabase/config.toml and supabase/functions
- Lab proof
- Not lab-proven: file and line in the report
What it is
Edge Functions are the right home for the service role key: it lives in a server-side secret and the browser never sees it. But the key bypasses row level security, so the function itself becomes the access rule. PL-017 means a function uses SUPABASE_SERVICE_ROLE_KEY, has the platform's sign-in check switched off with verify_jwt = false in supabase/config.toml, and has no check of its own on who is calling.
A function's URL is not a secret. It follows a fixed pattern, https://<project>.supabase.co/functions/v1/<name>, and appears in your frontend bundle. Anyone can call it with whatever body they like.
Turning verify_jwt back on is not the whole answer either. On projects using the legacy API keys, the public anon key is itself a valid JWT, so the platform check proves only that the caller has your public key, which every visitor does. The check that counts is inside the function: ask Supabase Auth whose token this is.
Why AI builders produce it
The service key makes things work: the function reads every table without fighting policies. JWT verification gets switched off when a call from a webhook, a cron job or a not-yet-signed-in page fails with a 401. The generated function handles the happy path, a call from your own app, and nothing tests a call from anywhere else.
Example of vulnerable code
Simplified from the patterns we see in AI-built apps. Synthetic: not taken from any real app.
// supabase/functions/delete-account/index.ts
import { createClient } from 'npm:@supabase/supabase-js@2';
Deno.serve(async (req) => {
const { userId } = await req.json();
const admin = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
);
// Nothing checks who is calling: anyone can delete any account
await admin.auth.admin.deleteUser(userId);
return Response.json({ ok: true });
});
# supabase/config.toml
[functions.delete-account]
verify_jwt = false
Check it yourself
# 1. Functions with the platform sign-in check switched off
grep -n -B2 "verify_jwt *= *false" supabase/config.toml
# 2. Which functions use the service key
grep -rln "SUPABASE_SERVICE_ROLE_KEY" supabase/functions
# 3. Of those, which never ask who the caller is
for f in $(grep -rl "SUPABASE_SERVICE_ROLE_KEY" supabase/functions); do
grep -qE "auth\.getUser|getClaims|constructEvent" "$f" || echo "no caller check: $f"
done
Shared helpers in supabase/functions/_shared count too, so read the hits rather than trusting the grep. To try a call without a sign-in, run the function locally (supabase start, then supabase functions serve) and call http://127.0.0.1:54321/functions/v1/<name> with only the anon key. Never test a destructive function against real users.
The fix
Identify the caller from their token before touching the service key, and take the user's id from the token rather than the body.
import { createClient } from 'npm:@supabase/supabase-js@2';
Deno.serve(async (req) => {
const token = req.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) return new Response('Unauthorized', { status: 401 });
const admin = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
);
// Ask Supabase Auth whose token this is. The anon key has no user, so it fails here.
const { data: { user }, error } = await admin.auth.getUser(token);
if (error || !user) return new Response('Unauthorized', { status: 401 });
// Callers can delete only their own account
await admin.auth.admin.deleteUser(user.id);
return Response.json({ ok: true });
});
Then set verify_jwt = true for the function again, unless it must accept calls without a Supabase session (a webhook, a cron job). Those need their own proof of origin: a verified signature (PL-021) or a shared secret header. For admin-only functions, check the role after getUser, from app_metadata or a roles table.
Code findings come with the file, the line and a one-line fix in the report, for your engineer to make. PatchLabs never pushes to your repo.
How PatchLabs finds it
[SCAN] supabase/config.toml:14 verify_jwt = false delete-account/index.ts:6 service key, no caller check LEAK [FIX] verify the caller with getUser [RESCAN] 0 open functions OK
PatchLabs Code reads your repo; it never calls your functions. It flags a function when three things line up: verify_jwt = false for it in supabase/config.toml, the service role key used in its files, and no caller check of its own (such as auth.getUser, a webhook signature check or a secret header). A function with verify_jwt = false that does check its caller, or doesn't use the service key, is listed as information, not as this finding. The report gives the file and line; a re-check confirms it's gone.
When the pattern is intended
Functions that must be callable without a Supabase session are normal: payment webhooks, cron jobs, a public contact form. They are fine when something else proves the caller (a signature, a secret header), or when the function only does something harmless for anyone. A public function that only returns public data shouldn't need the service key at all.
Why it’s rated high
High: the function can do anything the service key can, which is everything, but an attacker has to find it and work out what body it expects. In practice that is a look at your frontend bundle.
Related findings
Edge function trusts a user id sent in the request
The function acts on whichever user id the request names, so any user can act as any other.
PL-010Service role key in client code
The key that ignores every access rule, shipped to every visitor's browser.
PL-021Payment webhook doesn't verify its signature
Anyone can send your app a fake 'payment succeeded' event and get the paid plan for free.
Guides
- How to secure a Lovable app’s Supabase database
- How to secure Supabase Edge Functions
- Verify Stripe webhook signatures in a Supabase Edge Function
- Pre-launch security checklist for vibe-coded apps
Free check
Does your app have PL-017?
Paste your public repo. PatchLabs Code reads it for this and every other code finding, and the Lab rebuilds your database to test who can read what.
Last reviewed 10 Nov 2026. Finding IDs are stable: link to them freely. Spotted a mistake? Tell us.