Back to Supabase

Securing Edge Functions

apps/docs/content/guides/functions/auth.mdx

1.26.0512.3 KB
Original Source
<Admonition type="caution">

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.

</Admonition>

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:

toml
[functions.stripe-webhook]
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.

toml
[functions.notes]
verify_jwt = true
ts
import { createClient } from 'npm:@supabase/supabase-js@2'

const SUPABASE_PUBLISHABLE_KEYS = JSON.parse(Deno.env.get('SUPABASE_PUBLISHABLE_KEYS')!)

Deno.serve((req) => {
  const supabase = createClient(
    Deno.env.get('SUPABASE_URL')!,
    SUPABASE_PUBLISHABLE_KEYS['default'],
    { global: { headers: { Authorization: req.headers.get('Authorization')! } } }
  )

  // your business logic. queries run as the caller
  return Response.json({ ok: true })
})

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.

toml
[functions.run-automations]
verify_jwt = false
ts
import { createClient } from 'npm:@supabase/supabase-js@2'

const SUPABASE_SECRET_KEYS = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!)

Deno.serve((req) => {
  if (req.headers.get('apikey') !== Deno.env.get('INTERNAL_AUTOMATIONS_KEY')) {
    return Response.json({ error: 'forbidden' }, { status: 401 })
  }

  const supabase = createClient(Deno.env.get('SUPABASE_URL')!, SUPABASE_SECRET_KEYS['default'])

  // your business logic. queries run with the service role
  return Response.json({ ok: true })
})
<Admonition type="tip">

Never expose a secret key to the browser. Store it as a function secret.

</Admonition>

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.

toml
[functions.health]
verify_jwt = false
ts
Deno.serve(() => {
  // your business logic
  return Response.json({ ok: true })
})

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.

toml
[functions.stripe-webhook]
verify_jwt = false
ts
import Stripe from 'npm:stripe'

const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!)

Deno.serve(async (req) => {
  const signature = req.headers.get('stripe-signature') ?? ''
  const body = await req.text()

  try {
    stripe.webhooks.constructEvent(body, signature, Deno.env.get('STRIPE_WEBHOOK_SECRET')!)
  } catch {
    return new Response('bad signature', { status: 400 })
  }

  // your business logic. handle the event
  return Response.json({ received: true })
})

Simplifying with @supabase/server

The @supabase/server package wraps your handler, checks the caller's credentials against a declared auth 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
'publishable:<name>'A named publishable key on apikey
'none'Any caller, no check (for signed webhooks)
<Admonition type="tip">

See the @supabase/server docs for the full list of modes.

</Admonition>

Authenticated user calls [#authenticated-user-calls-with-server-sdk]

auth: 'user' pairs with verify_jwt = true. The platform validates the JWT, and the SDK hands you ctx.supabase already scoped to the caller.

ts
import { withSupabase } from 'npm:@supabase/server'

export default {
  fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => {
    // your business logic. ctx.supabase is scoped to the caller
    return Response.json({ email: ctx.userClaims?.email })
  }),
}

Service-to-service calls [#service-to-service-calls-with-server-sdk]

auth: '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.

ts
import { withSupabase } from 'npm:@supabase/server'

export default {
  fetch: withSupabase({ auth: 'secret:automations' }, async (_req, ctx) => {
    // your business logic. ctx.supabaseAdmin bypasses RLS
    return Response.json({ ok: true })
  }),
}
<Admonition type="tip">

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.

</Admonition>

Public functions [#public-functions-with-server-sdk]

The SDK adds nothing to a truly public function. Use the raw pattern from the previous section. If you need a Supabase client anyway, auth: 'none' with verify_jwt = false skips every check and treats every caller as anonymous.

External webhooks [#external-webhooks-with-server-sdk]

Use auth: 'none' to skip the SDK's credential check, then verify the provider's signature inside the handler. Keep verify_jwt = false.

ts
import { withSupabase } from 'npm:@supabase/server'
import Stripe from 'npm:stripe'

const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!)

export default {
  fetch: withSupabase({ auth: 'none' }, async (req, ctx) => {
    const signature = req.headers.get('stripe-signature') ?? ''
    const body = await req.text()

    try {
      stripe.webhooks.constructEvent(body, signature, Deno.env.get('STRIPE_WEBHOOK_SECRET')!)
    } catch {
      return new Response('bad signature', { status: 400 })
    }

    // your business logic. ctx.supabaseAdmin available for db work
    return Response.json({ received: true })
  }),
}
<Admonition type="caution">

auth: 'none' 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.

</Admonition>

Combining modes

Functions that answer both users and internal callers take an array on auth. Modes are tried in order. The first match wins, and ctx.authMode tells you which matched.

ts
import { withSupabase } from 'npm:@supabase/server'

export default {
  fetch: withSupabase({ auth: ['user', 'secret:automations'] }, 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 so you stay in control.

ts
import { createSupabaseContext } from 'npm:@supabase/server'

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

<Admonition type="note">

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.

</Admonition>