Edge Functions

Securing Edge Functions

Authentication patterns for Edge Functions


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.

HeaderValueUsed for
AuthorizationBearer <user-jwt>A user signed in through Supabase Auth
apikeysb_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_jwt on for functions that are only called with a user JWT, such as functions invoked from the client through supabase.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_jwt off for functions that are called without an Authorization header, 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]
2
verify_jwt = false

For 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]
2
verify_jwt = true
1
import { createClient } from 'npm:@supabase/supabase-js@2'
2
3
const SUPABASE_PUBLISHABLE_KEYS = JSON.parse(Deno.env.get('SUPABASE_PUBLISHABLE_KEYS')!)
4
5
Deno.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
)
11
12
// your business logic. queries run as the caller
13
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]
2
verify_jwt = false
1
import { createClient } from 'npm:@supabase/supabase-js@2'
2
3
const SUPABASE_SECRET_KEYS = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!)
4
5
Deno.serve((req) => {
6
if (req.headers.get('apikey') !== Deno.env.get('INTERNAL_AUTOMATIONS_KEY')) {
7
return Response.json({ error: 'forbidden' }, { status: 401 })
8
}
9
10
const supabase = createClient(Deno.env.get('SUPABASE_URL')!, SUPABASE_SECRET_KEYS['default'])
11
12
// your business logic. queries run with the service role
13
return Response.json({ ok: true })
14
})

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]
2
verify_jwt = false
1
Deno.serve(() => {
2
// your business logic
3
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]
2
verify_jwt = false
1
import Stripe from 'npm:stripe'
2
3
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!)
4
5
Deno.serve(async (req) => {
6
const signature = req.headers.get('stripe-signature') ?? ''
7
const body = await req.text()
8
9
try {
10
stripe.webhooks.constructEvent(body, signature, Deno.env.get('STRIPE_WEBHOOK_SECRET')!)
11
} catch {
12
return new Response('bad signature', { status: 400 })
13
}
14
15
// your business logic. handle the event
16
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.

ModeAccepts
'user'A valid user JWT on Authorization
'secret:<name>'A named secret key on apikey
'always'Any caller, no check (for signed webhooks)

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.

1
import { withSupabase } from 'npm:@supabase/server'
2
3
export default {
4
fetch: withSupabase({ allow: 'user' }, async (_req, ctx) => {
5
// your business logic. ctx.supabase is scoped to the caller
6
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.

1
import { withSupabase } from 'npm:@supabase/server'
2
3
export default {
4
fetch: withSupabase({ allow: 'secret:automations' }, async (_req, ctx) => {
5
// your business logic. ctx.supabaseAdmin bypasses RLS
6
return Response.json({ ok: true })
7
}),
8
}

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.

1
import { withSupabase } from 'npm:@supabase/server'
2
import Stripe from 'npm:stripe'
3
4
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!)
5
6
export default {
7
fetch: withSupabase({ allow: 'always' }, async (req, ctx) => {
8
const signature = req.headers.get('stripe-signature') ?? ''
9
const body = await req.text()
10
11
try {
12
stripe.webhooks.constructEvent(body, signature, Deno.env.get('STRIPE_WEBHOOK_SECRET')!)
13
} catch {
14
return new Response('bad signature', { status: 400 })
15
}
16
17
// your business logic. ctx.supabaseAdmin available for db work
18
return Response.json({ received: true })
19
}),
20
}

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.

1
import { withSupabase } from 'npm:@supabase/server'
2
3
export 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 them
7
return Response.json({ ok: true })
8
}
9
10
// your business logic for service calls. ctx.supabaseAdmin bypasses RLS
11
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.

1
import { createSupabaseContext } from 'npm:@supabase/server'
2
3
export 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.

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, which the SDK also accepts as a fallback: SUPABASE_PUBLISHABLE_KEY and SUPABASE_SECRET_KEY.