Skip to content
Edge Functions

Securing Edge Functions

Authentication patterns for Edge Functions

Secure an Edge Function by declaring which credentials it accepts. The withSupabase wrapper from the @supabase/server package on GitHub checks each caller against the auth mode you set. Your handler receives a preconfigured Supabase client on ctx.

For how authorization headers and the verify_jwt platform check work, see Authorization headers.

The wrapper accepts four auth modes:

ModeAccepts
'user'A valid user JWT on Authorization
'secret'A secret key on apikey
'publishable'A publishable key on apikey
'none'Any caller, no check (for signed webhooks)

Authenticated user calls#

When a signed-in user calls a function, the request carries the user's session JWT on the Authorization header. Your app usually makes that call through supabase.functions.invoke. The default is verify_jwt = true. The platform validates the JWT before your handler runs. Use auth: 'user' to get a ctx.supabase scoped to the caller's Row Level Security (RLS) policies.

import { withSupabase } from 'npm:@supabase/server@1'
export default {
fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => {
const { supabase, supabaseAdmin, userClaims, jwtClaims, authMode } = ctx
// supabase — RLS-scoped to the authenticated user
// supabaseAdmin — bypasses RLS (service role)
// userClaims — user identity from JWT (id, email, role)
// jwtClaims — full JWT claims
// authMode — which auth mode matched
// your business logic goes here
return Response.json({ email: ctx.userClaims?.email })
}),
}

Service-to-service calls#

Cron jobs, workers, pg_net, and other Edge Functions make calls with a secret key on the apikey header rather than a user JWT. Disable verify_jwt and use auth: 'secret'. The wrapper validates the key against any secret key in your project's API keys, and gives your handler ctx.supabaseAdmin for privileged work.

import { withSupabase } from 'npm:@supabase/server@1'
export default {
fetch: withSupabase({ auth: 'secret' }, async (_req, ctx) => {
// your business logic. ctx.supabaseAdmin bypasses RLS
return Response.json({ ok: true })
}),
}

Public functions#

For a genuinely public function, such as a health check, use auth: 'none' with verify_jwt = false so anonymous callers can reach the handler.

[functions.health]
verify_jwt = false
import { withSupabase } from 'npm:@supabase/server@1'
export default {
fetch: withSupabase({ auth: 'none' }, async () => {
// your business logic
return Response.json({ ok: true })
}),
}

auth: 'none' accepts every caller. For a function that authenticates callers itself, see the External webhooks section of this page.

External webhooks#

External providers such as Stripe or GitHub don't send Supabase credentials. They sign the request body with their own shared secret. Use auth: 'none' to skip the wrapper's credential check, then verify the provider's signature inside the handler. Keep verify_jwt = false.

import { withSupabase } from 'npm:@supabase/server@1'
import Stripe from 'npm:stripe'
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!)
// Deno has no synchronous Node crypto, so signature verification must go through
// Stripe's SubtleCryptoProvider. The synchronous `constructEvent()` throws
// "SubtleCryptoProvider cannot be used in a synchronous context" on this runtime.
const cryptoProvider = Stripe.createSubtleCryptoProvider()
export default {
fetch: withSupabase({ auth: 'none' }, async (req, ctx) => {
const signature = req.headers.get('stripe-signature') ?? ''
const body = await req.text()
try {
await stripe.webhooks.constructEventAsync(
body,
signature,
Deno.env.get('STRIPE_WEBHOOK_SECRET')!,
undefined,
cryptoProvider
)
} catch (err) {
// Log the reason so a configuration error isn't mistaken for a forged payload.
console.error('Stripe signature verification failed:', err)
return new Response('bad signature', { status: 400 })
}
// your business logic. ctx.supabaseAdmin available for database work
return Response.json({ received: true })
}),
}

Combining modes#

Functions that answer both users and internal callers take an array on auth. The wrapper tries each mode in order and uses the first one that matches. ctx.authMode tells you which mode matched.

import { withSupabase } from 'npm:@supabase/server@1'
export default {
fetch: withSupabase({ auth: ['user', 'secret'] }, async (req, ctx) => {
if (ctx.authMode === 'user') {
// your business logic for user calls. ctx.supabase is scoped to them
return Response.json({ ok: true })
}
// your business logic for service calls. ctx.supabaseAdmin bypasses RLS
return Response.json({ ok: true })
}),
}

Custom error responses#

To shape the 401 response yourself, use createSupabaseContext instead of withSupabase. It returns a { data, error } tuple instead of rejecting the request for you.

import { createSupabaseContext } from 'npm:@supabase/server@1'
export default {
fetch: async (req: Request) => {
const { data: ctx, error } = await createSupabaseContext(req, { auth: 'user' })
if (error) {
return Response.json({ message: error.message, code: error.code }, { status: error.status })
}
return Response.json({ message: `hello ${ctx.userClaims?.email}` })
},
}

Environment variables#

@supabase/server reads its configuration from a standard set of environment variables, which the Supabase Platform and the Supabase CLI provision for you.

VariableWhat it is
SUPABASE_URLYour project URL
SUPABASE_PUBLISHABLE_KEYSNamed publishable keys as a JSON object
SUPABASE_SECRET_KEYSNamed secret keys as a JSON object
SUPABASE_JWKSJSON Web Key Set used to verify user JWTs

Local development with the CLI uses a single-key setup. @supabase/server also accepts SUPABASE_PUBLISHABLE_KEY and SUPABASE_SECRET_KEY as a fallback.