LEAKCADE

Supabase Edge Functions security: every function is a public URL

Updated 7 min read

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.

One public URL, two outcomesTwo paths for a request to an Edge Function. Wrong: an anonymous caller reaches an admin client and gets every user's rows. Right: the caller's JWT is checked and queries run as that user under RLS.WRONG1Anyone hits thefunction URL2verify_jwt off,no user check3Admin client, nofilter4Every user'srows come backRIGHT1Caller sendstheir user JWT2verify_jwt on,getUser checks3Client scoped tothe caller4RLS returns onlytheir rowsWRONG1Anyone hits the function URL2verify_jwt off, no user check3Admin client, no filter4Every user's rows come backRIGHT1Caller sends their user JWT2verify_jwt on, getUser checks3Client scoped to the caller4RLS returns only their rows
Same URL, different outcome.

How attackers use an open or over-trusting function

Most Edge Function holes come from four habits.

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.

BEFOREAdmin client and a user id from the body
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 })
})
AFTERCaller's Authorization header, user checked
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 never trusts a body user id. RLS picks the rows, so the orders table needs policies.

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 authverify_jwt
Signed-in users'user'true (default)
Cron jobs, workers, other functions'secret'false
Stripe or GitHub webhooks'none' + signature checkfalse
Public health check'none'false

Based on the Supabase docs for securing Edge Functions.

Secrets, webhooks, CORS headers and rate limits

For secrets:

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:

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/.

  1. List every verify_jwt = false

    Search supabase/config.toml for verify_jwt = false. Each hit needs a reason: a cron or service call, a webhook, or a truly public endpoint such as a health check.

  2. Choose an auth mode per function

    Use the table above. Signed-in users get verify_jwt on and auth: 'user'. Cron and service calls get auth: 'secret'. Webhooks get auth: 'none' plus a signature check.

  3. Run queries as the caller

    Forward the Authorization header to createClient or use ctx.supabase. Read the user id from getUser or ctx.userClaims, never from the request body.

  4. 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.

  5. Move secrets out of code

    Run supabase secrets set for each key and add your env files to .gitignore. Check the names with supabase secrets list.

  6. Verify, limit, redeploy

    Add signature checks to webhooks and rate limits to costly functions. Then run supabase functions deploy and 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.

  1. 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'
  1. 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'
  1. 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"}'
  1. 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"}'
  1. 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.

FIX PROMPT
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

  1. Supabase docs: Securing Edge Functions
  2. Supabase docs: Edge Function environment variables and secrets
  3. Supabase docs: CORS for Edge Functions
  4. Supabase docs: Getting started with Edge Functions
  5. 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 repo

Leakcade 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

Patch Notes by Leakcade

A short weekly email: what changed in AI coding tools, one real security mistake, one prompt you can reuse.