Supabase Edge Functions security: every function is a public URL
Supabase Edge Functions security starts with one fact: every function has a public URL. Keep verify_jwt on (it makes Supabase check the caller's login token, a JWT, before your code runs), check who the caller is, and query with a client scoped to that user instead of the secret (service-role) key. Webhooks are the exception and need a signature check instead.
QUICK CHECKLIST
- Keep verify_jwt on, except webhooks, service calls, public endpoints
- Check the caller with auth.getUser, 401 (Unauthorized) if no user
- Query with the caller's Authorization header, not the secret key
- Take the user id from the verified token, never the body
- Store keys with supabase secrets set, never in code
- Verify webhook signatures and rate-limit costly functions
What Supabase Edge Functions security means
An Edge Function is a TypeScript function that runs on Deno, a JavaScript and TypeScript runtime similar to Node.js. Supabase serves it at a URL like https://YOUR_PROJECT_ID.supabase.co/functions/v1/your-function. Anyone on the internet can send a request to that URL, including people who never opened your app.
So the function has to settle three things itself: who is calling, what that caller may see, and which secrets the code can reach. The platform helps with the first through the verify_jwt setting, which is on by default. The rest is your code.
How attackers use an open or over-trusting function
Most Edge Function holes come from four habits.
verify_jwtswitched off to clear a 401 during development, then never switched back on. The Supabase docs warn that this removes the platform's check, so your own code is the only thing verifying the caller.- A client built with the secret key. It bypasses Row Level Security (RLS, the database rules that limit which rows each user can read), so an unfiltered query returns every user's rows.
- A user id read from the request body. The attacker types someone else's id and the function obliges.
- A function that sends email or calls a paid AI API with no limit. Someone finds the URL and runs up your bill.
A JWT that passes verify_jwt doesn't even prove a user signed in, because the legacy anon key is itself a valid JWT. The function still has to get the user from the verified token, reject callers without one, and scope every query to that user.
Wrong and right: check the caller, query as the caller
Here is a shape that's easy to end up with, next to the fix. Both functions return orders.
import { createClient } from 'npm:@supabase/supabase-js@2'
const keys = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!)
const admin = createClient(Deno.env.get('SUPABASE_URL')!, keys['default'])
Deno.serve(async (req) => {
const { userId } = await req.json() // the caller controls this
const { data } = await admin
.from('orders')
.select('*')
.eq('user_id', userId)
return Response.json({ data })
})import { createClient } from 'npm:@supabase/supabase-js@2'
const keys = JSON.parse(Deno.env.get('SUPABASE_PUBLISHABLE_KEYS')!)
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')!, keys['default'], {
global: { headers: { Authorization: authHeader } },
})
const { data: { user }, error } = await supabase.auth.getUser(
authHeader.replace('Bearer ', '')
)
if (error || !user) return new Response('Unauthorized', { status: 401 })
// RLS applies, so this returns only the caller's rows
const { data } = await supabase.from('orders').select('*')
return Response.json({ data })
})The fixed version forwards the caller's Authorization header to createClient, so every query runs as that user and Row Level Security applies. It also calls auth.getUser with the token and returns 401 if there's no user. Make sure orders has policies (Supabase RLS mistakes), and see getSession vs getUser for why to verify with getUser.
The Supabase docs also offer a wrapper from @supabase/server that does the check for you:
import { withSupabase } from 'npm:@supabase/server@1'
export default {
fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => {
const { data } = await ctx.supabase.from('orders').select('*')
return Response.json({ data })
}),
}The wrapper hands you ctx.supabase (RLS-scoped) and ctx.supabaseAdmin (bypasses RLS). Use the admin client only for work that must cross those policies, and filter by the caller's id from ctx.userClaims. Without that filter, a handler querying a shared table through ctx.supabaseAdmin returns every user's rows.
Pick the auth mode by who calls the function:
| withSupabase auth | verify_jwt | |
|---|---|---|
| Signed-in users | 'user' | true (default) |
| Cron jobs, workers, other functions | 'secret' | false |
| Stripe or GitHub webhooks | 'none' + signature check | false |
| Public health check | 'none' | false |
Based on the Supabase docs for securing Edge Functions.
Secrets, webhooks, CORS headers and rate limits
For secrets:
- Set production secrets with
supabase secrets set NAME=value, or push a whole file withsupabase secrets set --env-file .env. - Read them in the function with
Deno.env.get('NAME'). - Locally, the function reads
supabase/functions/.env. Add that file, and any.envyou push, to.gitignore. - Never put these values in a
NEXT_PUBLIC_variable (Next.js sends those to the browser). If one leaks, follow the leaked API key guide.
For webhooks, Stripe and GitHub don't send Supabase credentials. They sign the body with a secret shared between you and them, so your function can recompute the signature and compare. To handle them:
- Set
verify_jwt = falsefor that function insupabase/config.toml. - Use
auth: 'none'if you use the wrapper. - Do webhook signature verification inside the handler before any database write.
- Return 400 when it fails.
See webhook signature verification for the general idea.
CORS (Cross-Origin Resource Sharing) is the browser rule that decides which websites may call your function. Before such a call, the browser sends a preflight OPTIONS request, so the function has to answer it. withSupabase handles this. Without the wrapper, import corsHeaders from npm:@supabase/supabase-js@^2/cors (supabase-js v2.95.0 and later). CORS only restrains browsers. curl ignores it, so it protects nothing on its own. More in CORS misconfiguration.
The Supabase pages cited here don't describe per-user limits for your function, so check the current docs for platform limits. For anything that costs money or sends messages, add your own: count calls per user id per time window in shared storage (a Postgres table or Redis, not an in-memory variable, since isolates don't share state) and return 429 (Too Many Requests) past the limit. See login rate limiting.
How to lock down your Edge Functions, step by step
Do this for every folder in supabase/functions/.
List every verify_jwt = false
Search
supabase/config.tomlforverify_jwt = false. Each hit needs a reason: a cron or service call, a webhook, or a truly public endpoint such as a health check.Choose an auth mode per function
Use the table above. Signed-in users get
verify_jwton andauth: 'user'. Cron and service calls getauth: 'secret'. Webhooks getauth: 'none'plus a signature check.Run queries as the caller
Forward the
Authorizationheader tocreateClientor usectx.supabase. Read the user id fromgetUserorctx.userClaims, never from the request body.Fence in the admin client
Keep the secret key client only where a query must cross RLS. Filter by the verified user id every time.
Move secrets out of code
Run
supabase secrets setfor each key and add your env files to.gitignore. Check the names withsupabase secrets list.Verify, limit, redeploy
Add signature checks to webhooks and rate limits to costly functions. Then run
supabase functions deployand repeat the tests below.
Test it yourself
Run these only against your own project. Replace the placeholders with your function name, project id and test users.
- Call the function with no credentials. Good: 401. Bad: 200 with data.
curl -i -X POST 'https://YOUR_PROJECT_ID.supabase.co/functions/v1/get-orders'
- Call it with only the publishable key. A function meant for signed-in users should still return 401.
curl -i -X POST 'https://YOUR_PROJECT_ID.supabase.co/functions/v1/get-orders' \ -H 'apikey: YOUR_PUBLISHABLE_KEY'
- Sign in as test user A, copy A's access token, and send user B's id in the body. Good: only A's rows or nothing. Bad: B's rows.
curl -i -X POST 'https://YOUR_PROJECT_ID.supabase.co/functions/v1/get-orders' \
-H 'Authorization: Bearer USER_A_ACCESS_TOKEN' \
-H 'Content-Type: application/json' \
-d '{"userId":"USER_B_ID"}'- Post an unsigned fake event to each webhook function. Good: 400. Bad: 200 and a changed row in your database.
curl -i -X POST 'https://YOUR_PROJECT_ID.supabase.co/functions/v1/stripe-webhook' \
-H 'Content-Type: application/json' \
-d '{"type":"checkout.session.completed"}'- Search your repo. Every hit needs an explanation.
grep -rnE "SERVICE_ROLE|SECRET_KEY|supabaseAdmin|sb_secret_|eyJ" supabase/functions
Fix prompt you can paste
Give this to Cursor or Claude Code from the root of your repo.
Audit every Supabase Edge Function in supabase/functions/ and supabase/config.toml.
1. List each function with its verify_jwt setting. Flag every verify_jwt = false and say why it is needed (cron, webhook or public).
2. Find every client created with SUPABASE_SERVICE_ROLE_KEY, SUPABASE_SECRET_KEYS or SUPABASE_SECRET_KEY. If the function serves signed-in users, replace it with a client built from the publishable key that forwards the request's Authorization header (global.headers), or use withSupabase({ auth: 'user' }) and ctx.supabase.
3. Verify the caller with supabase.auth.getUser(token) and return 401 if there is no user. Never read user_id from the request body.
4. For webhooks: keep verify_jwt = false, verify the provider signature on the raw body before any database write, and return 400 on failure.
5. Search for hardcoded keys, console.log of secrets, and secrets returned in responses. Move them to supabase secrets set.
6. Add a per-user rate limit to any function that calls a paid API or sends email.
7. Flag any CORS handler that returns Access-Control-Allow-Origin: * on a function that touches user data, any getSession() used to identify the caller, and any query filtered by an id from req.json(), the URL or the query string instead of the verified user's id.
Output a table of file, line, problem and fix. Then apply the fixes and explain each change in one or two sentences. Tell me before you touch any RLS policy.You can also run Leakcade on your app afterwards.
Further reading
- Supabase docs: Securing Edge Functions
- Supabase docs: Edge Function environment variables and secrets
- Supabase docs: CORS for Edge Functions
- Supabase docs: Getting started with Edge Functions
- Supabase docs: API keys
FAQ
What does verify_jwt do in a Supabase Edge Function?
With verify_jwt = true, the default, the platform validates the JWT before your handler runs. Turn it off only for webhooks, secret-key calls, and genuinely public endpoints. A valid JWT doesn't prove a user signed in, because the legacy anon key is itself a valid JWT. So call getUser in the handler and have RLS policies on every table you query.
How do I get the user in an Edge Function from the Authorization header?
Pass the header to createClient through global.headers, then call supabase.auth.getUser(token) with the token from that header. Return 401 if there's no user. With withSupabase and auth: 'user', the identity is on ctx.userClaims.
How do I set secrets for Supabase Edge Functions?
Run supabase secrets set NAME=value, or supabase secrets set --env-file .env for a file. Check them with supabase secrets list and read them with Deno.env.get('NAME'). Names can't start with SUPABASE_.
Does the service role key in an Edge Function bypass RLS?
Yes. Secret keys map to the service_role Postgres role, which has the BYPASSRLS attribute, so it skips every RLS policy. Use a client built with the caller's Authorization header, or ctx.supabase from the wrapper.
Do webhooks and cron jobs need CORS headers?
No. CORS is a browser rule, so it only matters when a web page calls your function. Webhooks and cron calls come from servers and need authentication instead: a signature check for webhooks, a secret key for cron.
Want a second pair of eyes?
We review your whole repo and write a fix prompt for every finding. $19, report within an hour.
Check my repoLeakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms