Auth and server code
How to secure Supabase Edge Functions
To secure a Supabase Edge Function, verify who is calling inside the function by passing the request’s token to supabase.auth.getUser(), take the user’s id from that result rather than from the request body, and use the service role key only after those checks, or not at all. Functions that can’t require a sign-in, like webhooks and cron jobs, need another proof of origin: a signature or a shared secret.
TL;DR
- A function’s URL is public and in your bundle. Anyone can call it with any body.
- The platform’s JWT check (
verify_jwt) accepts the public anon key on projects using legacy keys, so it isn’t proof of a signed-in user. Check withgetUser(). - Prefer a client built with the caller’s token, so your RLS policies apply. Use the service key only when you must.
- CORS headers control which sites can read responses. They are not access control.
What you’re defending against
Every Edge Function is reachable at https://<project>.supabase.co/functions/v1/<name>. The name is in your frontend code, so assume anyone can find it and call it with whatever headers and body they like. A function is only as safe as the checks it runs before doing something.
Functions are also where the service role key lives, as an automatically provided secret (SUPABASE_SERVICE_ROLE_KEY). That key bypasses every row level security policy. A function that uses it without checking the caller turns your database’s rules off for anyone who finds the URL (PL-017).
What verify_jwt does and doesn’t do
By default the platform rejects calls to a function that don’t carry a valid JWT in the Authorization header. You can switch that off per function in supabase/config.toml:
[functions.stripe-webhook]
verify_jwt = falseTwo things to know. First, on projects using the legacy API keys, the anon key is itself a valid JWT, so verify_jwt = true proves only that the caller has your public key, which every visitor has. Second, switching it off is sometimes right (webhooks, cron), but then the function must prove the caller some other way. Either way, the real check belongs inside the function.
The safe default: act as the caller
The simplest secure function doesn’t use the service key at all. It builds a client that carries the caller’s own token, so every query runs as that user and your RLS policies apply, exactly as they do in the browser:
import { createClient } from 'npm:@supabase/supabase-js@2';
Deno.serve(async (req) => {
const authHeader = req.headers.get('Authorization');
if (!authHeader) return new Response('Unauthorized', { status: 401 });
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')!,
{ global: { headers: { Authorization: authHeader } } },
);
// Asks Supabase Auth who the token belongs to. The anon key has no user, so it fails.
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) return new Response('Unauthorized', { status: 401 });
// Runs as the user: RLS decides which rows come back
const { data, error: qErr } = await supabase
.from('orders')
.select('id, total, created_at')
.eq('user_id', user.id);
if (qErr) return new Response(qErr.message, { status: 400 });
return Response.json(data);
});When you need the service key
Some jobs genuinely need it: deleting an auth user, writing to a table users can’t write, calling the admin API. Verify the caller first, then decide whether they may do this specific thing:
import { createClient } from 'npm:@supabase/supabase-js@2';
const admin = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
);
Deno.serve(async (req) => {
const token = req.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) return new Response('Unauthorized', { status: 401 });
const { data: { user }, error } = await admin.auth.getUser(token);
if (error || !user) return new Response('Unauthorized', { status: 401 });
// The id comes from the verified token, never from the request body
const { error: delErr } = await admin.auth.admin.deleteUser(user.id);
if (delErr) return new Response(delErr.message, { status: 500 });
return Response.json({ ok: true });
});For admin-only functions, check the role after getUser: read it from user.app_metadata, or query your roles table with the service client. Never from user.user_metadata, which the user controls (why).
Never trust a user id from the request
The most common hole in functions that do check sign-in: the function reads userId from the body and acts on it (PL-022). Any signed-in user can send someone else’s id. User ids turn up in URLs, API responses and storage paths, so they aren’t secret. Use user.id from getUser(). If admins may act on other users, check the admin role, then accept a target id.
Functions without a signed-in user
- Payment webhooks: verify the provider’s signature over the raw body. Stripe webhook verification in an Edge Function.
- Cron jobs and internal calls: send a shared secret in a header and compare it in the function. Store it with
supabase secrets set CRON_SECRET=…. - Public endpoints (a contact form): don’t use the service key; use the anon key so RLS limits what the function can do, and add rate limiting or a CAPTCHA if abuse would cost you.
const expected = Deno.env.get('CRON_SECRET');
if (!expected || req.headers.get('x-cron-secret') !== expected) {
return new Response('Forbidden', { status: 403 });
}CORS: what it is and isn’t
Browsers need CORS headers before a page on your site can read a function’s response, which is why Supabase’s examples include a corsHeaders object with Access-Control-Allow-Origin: * and an OPTIONS handler. For a function that authenticates with a bearer token from localStorage, * is acceptable: another website can call the function, but it doesn’t have your user’s token, so it gets a 401.
CORS becomes a hole when an endpoint authenticates with cookies and either reflects any origin with Access-Control-Allow-Credentials: true, or is otherwise opened to every site (PL-026). Then any site your users visit can make signed-in requests and read the answers. If you use cookie sessions (common in Next.js apps), allow-list your own origins. Either way, CORS never replaces checking the caller: tools like curl ignore it entirely.
Secrets
- Set third-party keys with
supabase secrets set NAME=valueand read them withDeno.env.get('NAME'). They never appear in your repo or bundle. - Locally, keep them in
supabase/functions/.envand make sure it’s in.gitignore. - Don’t return secrets or raw error objects that might include them.
Checklist for every function
- It identifies the caller with
getUser(), a signature or a shared secret before doing anything. - It takes the user’s id from the verified token.
- It uses the caller’s token for queries, or the service key only after its own checks.
verify_jwt = falseonly where something else proves the caller.- Cookie-authenticated endpoints allow-list origins; none reflect any origin with credentials.
- Secrets come from
Deno.env, not the code.
Sources
- Supabase: Edge Functions auth
- Supabase: Edge Function secrets
- Supabase: CORS in Edge Functions
- MDN: Cross-Origin Resource Sharing
Related findings
The checks behind this guide, each with example code, a self-check and the fix.
Edge function open without sign-in while using the service key
PL-022Edge function trusts a user id sent in the request
PL-026Any website can call a signed-in function
PL-010Service role key in client code
Free check
Check your app for this, free.
Paste your public repo. PatchLabs rebuilds your database from its migrations, tests it as a visitor, a stranger and the owner, reads the code for keys and risky patterns, and hands you the fix.
Written by Manraj Singh Dhillon. Last checked 10 Nov 2026. Code samples are synthetic and run against your own project; nothing here names a real app. Spotted a mistake? Tell us.