Skip to main content

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

Choose an auth mode

Each mode names the credential a caller must present. withSupabase accepts four:

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

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

Secure your function

Each of the following sections covers one mode, then how to combine modes and shape your own error response. Every example wraps the handler in withSupabase and reads its Supabase client from ctx.

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. Use auth: 'user' to get a ctx.supabase scoped to the caller's Row Level Security (RLS) policies.

The default is verify_jwt = true, so the platform validates the JWT before your handler runs. Leave it on for user-facing functions. Turning it off to clear a 401 during development removes the platform's check, leaving withSupabase as the only thing that verifies the caller.

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 })
  }),
}

Reach for ctx.supabase when the handler serves the signed-in caller. It applies their RLS policies to every query. Use ctx.supabaseAdmin only for work that has to cross those policies.

Danger

A handler that queries a shared table through ctx.supabaseAdmin without filtering by the caller's ID returns every user's rows. ctx.supabaseAdmin bypasses Row Level Security, so filter by the caller's ID from ctx.userClaims, or use ctx.supabase instead.

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. To accept the key:

  1. In supabase/config.toml, disable the verify_jwt check for the function:

    [functions.your-function]
    verify_jwt = false
  2. In the function, use auth: 'secret'. The wrapper validates the key against the secret key named default 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 })
      }),
    }

To call a function from Postgres with the secret key, see Call an Edge Function for Database Webhooks and Scheduling Edge Functions for pg_cron.

Note

auth: 'secret' accepts only the key named default. To accept a different key, use auth: 'secret:<name>'. For example, auth: 'secret:automations' accepts only the secret key named automations. To accept any secret key on the project, use auth: 'secret:*'. Every project starts with a secret key named default, and you can add more. To name a new key, open Settings > API keys in the Supabase Dashboard. The same syntax works for publishable keys: auth: 'publishable:<name>' and auth: 'publishable:*'.

The Secret keys section of the Supabase Dashboard. A table with Name and API key columns lists two keys, "default" and "automations". Each row shows a masked sb_secret_ value with reveal and copy buttons.

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 })
  }),
}

Danger

auth: 'none' disables every credential check, so your handler is fully responsible for authenticating the caller. When the function reads or writes sensitive data, verify the caller yourself inside the handler.

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.

Note

@supabase/server configures itself the same way on other runtimes. Install it in your Node.js, Bun, Cloudflare Workers, or self-hosted Deno app, then set the same variables. For the full reference, see the environment variables guide in the @supabase/server repository.