Securing Edge Functions
Authentication patterns for Edge Functions
The patterns in this guide assume your project uses the new JWT signing keys and the new API keys. If you're still on legacy JWTs, see the Legacy JWT Secret guide.
Every request to an Edge Function passes through two layers of auth. First, a platform-level check (verify_jwt) runs before your code executes. Then, once the request reaches your handler, you decide what to do with the credentials the caller sent.
Understanding authorization headers#
Edge Functions care about two request headers. Sending the wrong credential in the wrong header is the most common source of 401 errors.
| Header | Value | Used for |
|---|---|---|
Authorization | Bearer <user-jwt> | A user signed in through Supabase Auth |
apikey | sb_publishable_... or sb_secret_... | Calls from clients or services |
A common mistake is sending a publishable or secret key as a bearer token: Authorization: Bearer sb_publishable_.... The new API keys are not JWTs. The platform check can't validate them, and your handler can't verify them as JWTs either. Instead, put API keys in the apikey header.
You can send both headers together. A signed-in user calling your function through supabase-js, for example, sends their session JWT in Authorization and the project's publishable key in apikey.
The verify_jwt platform check#
When verify_jwt is enabled (the default), the platform inspects the Authorization header of every request before your function runs. It expects a valid user JWT. If the header is missing, malformed, or signed with a different key, the platform returns a 401 error, and your code never executes.
The check validates legacy HS256 JWTs and JWTs signed with the new asymmetric signing keys.
The check does not accept an API key. Publishable and secret keys are not JWTs, so callers that send one in the Authorization header fail the check before their request reaches your handler.
Use the verify_jwt flag to match how the function is called:
- Leave
verify_jwton for functions that are only called with a user JWT, such as functions invoked from the client throughsupabase.functions.invoke. The platform rejects unauthenticated requests before they reach your code, and your handler can trust that a valid JWT is present. - Turn
verify_jwtoff for functions that are called without anAuthorizationheader, such as webhooks from external providers, or service-to-service calls that authenticate with an API key. These patterns are covered later in the guide.
Set the flag per function in supabase/config.toml:
1[functions.stripe-webhook]2verify_jwt = falseFor 401 failure modes and how to diagnose them, see Edge Function 401 error response.
Common auth patterns#
The sections below show the four patterns you'll reach for most often, written without an SDK so the auth moves are visible. Business logic is left as a placeholder. The next section shows the same four patterns using @supabase/server.
Authenticated user calls#
Keep verify_jwt enabled. The platform validates the JWT before your handler runs. Forward the Authorization header to the Supabase client so queries run under the caller's RLS policies.
1[functions.notes]2verify_jwt = true1import { createClient } from 'npm:@supabase/supabase-js@2'23const SUPABASE_PUBLISHABLE_KEYS = JSON.parse(Deno.env.get('SUPABASE_PUBLISHABLE_KEYS')!)45Deno.serve((req) => {6 const supabase = createClient(7 Deno.env.get('SUPABASE_URL')!,8 SUPABASE_PUBLISHABLE_KEYS['default'],9 { global: { headers: { Authorization: req.headers.get('Authorization')! } } }10 )1112 // your business logic. queries run as the caller13 return Response.json({ ok: true })14})Service-to-service calls#
Cron jobs, workers, pg_net, or another Edge Functions make calls with a secret key on the apikey header. These callers don't send a user JWT, so disable verify_jwt and validate the key yourself.
1[functions.run-automations]2verify_jwt = false1import { createClient } from 'npm:@supabase/supabase-js@2'23const SUPABASE_SECRET_KEYS = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!)45Deno.serve((req) => {6 if (req.headers.get('apikey') !== Deno.env.get('INTERNAL_AUTOMATIONS_KEY')) {7 return Response.json({ error: 'forbidden' }, { status: 401 })8 }910 const supabase = createClient(Deno.env.get('SUPABASE_URL')!, SUPABASE_SECRET_KEYS['default'])1112 // your business logic. queries run with the service role13 return Response.json({ ok: true })14})Never expose a secret key to the browser. Store it as a function secret.
Public functions#
For a genuinely public function, like a health check, no credential is required. Disable verify_jwt so anonymous callers can reach the handler.
1[functions.health]2verify_jwt = false1Deno.serve(() => {2 // your business logic3 return Response.json({ ok: true })4})External webhooks#
External providers like Stripe or GitHub don't send Supabase credentials. They sign the request body with their own shared secret. Disable verify_jwt and verify the signature before acting on the payload.
1[functions.stripe-webhook]2verify_jwt = false1import Stripe from 'npm:stripe'23const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!)45Deno.serve(async (req) => {6 const signature = req.headers.get('stripe-signature') ?? ''7 const body = await req.text()89 try {10 stripe.webhooks.constructEvent(body, signature, Deno.env.get('STRIPE_WEBHOOK_SECRET')!)11 } catch {12 return new Response('bad signature', { status: 400 })13 }1415 // your business logic. handle the event16 return Response.json({ received: true })17})Simplifying with @supabase/server#
The @supabase/server package wraps your handler, checks the caller's credentials against a declared allow mode, and hands you a pre-configured Supabase client on ctx. The same patterns above, written against the SDK, look like this.
| Mode | Accepts |
|---|---|
'user' | A valid user JWT on Authorization |
'secret:<name>' | A named secret key on apikey |
'always' | Any caller, no check (for signed webhooks) |
See the @supabase/server docs for the full list of modes.
Authenticated user calls#
allow: 'user' pairs with verify_jwt = true. The platform validates the JWT, and the SDK hands you ctx.supabase already scoped to the caller.
1import { withSupabase } from 'npm:@supabase/server'23export default {4 fetch: withSupabase({ allow: 'user' }, async (_req, ctx) => {5 // your business logic. ctx.supabase is scoped to the caller6 return Response.json({ email: ctx.userClaims?.email })7 }),8}Service-to-service calls#
allow: 'secret:<name>' validates the apikey header against the named secret key from your dashboard and gives you ctx.supabaseAdmin for privileged work. The <name> matches the name you gave the key. Keep verify_jwt = false.
1import { withSupabase } from 'npm:@supabase/server'23export default {4 fetch: withSupabase({ allow: 'secret:automations' }, async (_req, ctx) => {5 // your business logic. ctx.supabaseAdmin bypasses RLS6 return Response.json({ ok: true })7 }),8}Create a named secret key for each caller in the Settings > API keys section of the Dashboard. Give it a name like "automations", and share the generated sb_secret_... value with the service that calls this function.

Public functions#
The SDK adds nothing to a truly public function. Use the raw pattern from the previous section. If you need a Supabase client anyway, allow: 'always' with verify_jwt = false skips every check and treats every caller as anonymous.
External webhooks#
Use allow: 'always' to skip the SDK's credential check, then verify the provider's signature inside the handler. Keep verify_jwt = false.
1import { withSupabase } from 'npm:@supabase/server'2import Stripe from 'npm:stripe'34const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!)56export default {7 fetch: withSupabase({ allow: 'always' }, async (req, ctx) => {8 const signature = req.headers.get('stripe-signature') ?? ''9 const body = await req.text()1011 try {12 stripe.webhooks.constructEvent(body, signature, Deno.env.get('STRIPE_WEBHOOK_SECRET')!)13 } catch {14 return new Response('bad signature', { status: 400 })15 }1617 // your business logic. ctx.supabaseAdmin available for db work18 return Response.json({ received: true })19 }),20}allow: 'always' disables every credential check. Your handler is fully responsible for authenticating the caller. Never use it on an endpoint that reads or writes sensitive data without verifying the caller some other way.
Combining modes#
Functions that answer both users and internal callers take an array on allow. Modes are tried in order. The first match wins, and ctx.authType tells you which matched.
1import { withSupabase } from 'npm:@supabase/server'23export default {4 fetch: withSupabase({ allow: ['user', 'secret:automations'] }, async (req, ctx) => {5 if (ctx.authType === 'user') {6 // your business logic for user calls. ctx.supabase is scoped to them7 return Response.json({ ok: true })8 }910 // your business logic for service calls. ctx.supabaseAdmin bypasses RLS11 return Response.json({ ok: true })12 }),13}Custom error responses#
To shape the 401 response yourself, use createSupabaseContext instead of withSupabase. It returns a { data, error } tuple so you stay in control.
1import { createSupabaseContext } from 'npm:@supabase/server'23export default {4 fetch: async (req: Request) => {5 const { data: ctx, error } = await createSupabaseContext(req, { allow: 'user' })6 if (error) {7 return Response.json({ message: error.message, code: error.code }, { status: error.status })8 }9 return Response.json({ message: `hello ${ctx.userClaims?.email}` })10 },11}Environment variables#
@supabase/server reads its configuration from a standard set of environment variables. On the Supabase platform and in local development with the CLI, these are auto-provisioned.
| Variable | What it is |
|---|---|
SUPABASE_URL | Your project URL |
SUPABASE_PUBLISHABLE_KEYS | Named publishable keys as a JSON object |
SUPABASE_SECRET_KEYS | Named secret keys as a JSON object |
SUPABASE_JWKS | JSON Web Key Set used to verify user JWTs |
Local development with the CLI uses a single-key setup, which the SDK also accepts as a fallback: SUPABASE_PUBLISHABLE_KEY and SUPABASE_SECRET_KEY.
The same zero-config experience is available on other runtimes. Install @supabase/server in your Node.js, Bun, Cloudflare Workers, or self-hosted Deno app and set the environment variables above. See the package's environment variables guide for the full reference.