Summary
When an Edge Function is configured with a multi-mode auth chain that includes both 'user' and 'secret' (e.g. auth: ['user', 'secret']), the chain consistently rejects legitimate secret-mode service-to-service calls because the Edge Functions runtime auto-injects an Authorization: Bearer <jwt> header that the wrapper interprets as a (failed) user-mode attempt.
Per the docs:
Array syntax (auth: ['user', 'secret']) is first-match-wins. A present-but-invalid JWT rejects with InvalidCredentialsError — it does not silently downgrade to the next mode.
This is intentional, but combined with the runtime's automatic Authorization injection on verify_jwt = false functions it makes ['user', 'secret'] effectively unusable for callers that authenticate via the apikey header (e.g. pg_net from Postgres, or supabaseAdmin.functions.invoke() from another function).
After the rollout of new API keys (sb_secret_* / sb_publishable_*), the injected JWT carries issuer api.supabase.co/.../api-keys-jwt-issuer, which is not the auth/v1 issuer expected by user mode — so user mode rejects with InvalidCredentialsError and the chain terminates before secret is evaluated. (We hit this concretely after toggling the project's vanity domain, which migrated our service_role key to the new format.)
Reproduction
// supabase/functions/my-fn/index.ts
import { withSupabase } from 'npm:@supabase/server'
export default {
fetch: withSupabase({ auth: ['user', 'secret'] }, async (req, ctx) => {
return Response.json({ mode: ctx.authMode })
}),
}
# supabase/config.toml
[functions.my-fn]
verify_jwt = false
Call from another function with supabaseAdmin.functions.invoke('my-fn', { body: {} }), or from Postgres via pg_net with the secret key in apikey. The runtime adds Authorization: Bearer <platform-jwt>, withSupabase tries user first, fails, terminates → 401, never reaches secret.
Both legitimate callers (frontend with user JWT, service-to-service with secret key) cannot be served by the same function under the documented chain syntax.
Current workaround
Strip Authorization when the apikey is a recognizable secret key, before handing the request to withSupabase:
function stripAuthIfSecretApikey(req: Request): Request {
const apikey = req.headers.get('apikey') ?? ''
if (!apikey.startsWith('sb_secret_')) return req
const headers = new Headers(req.headers)
headers.delete('Authorization')
return new Request(req.url, {
method: req.method,
headers,
body: req.body,
redirect: req.redirect,
})
}
const wrapped = withSupabase({ auth: ['user', 'secret'] }, handler)
export default { fetch: (req: Request) => wrapped(stripAuthIfSecretApikey(req)) }
This is gated on sb_secret_* so it never affects user traffic, and becomes a no-op if the runtime ever stops injecting the header. It works, but it's surprising that every consumer of auth: ['user', 'secret'] on Edge Functions has to know about and apply this.
Proposed enhancements (any one would help)
- Disambiguate by
apikey first. Inside withSupabase, when evaluating a multi-mode chain, inspect apikey before deciding which mode to attempt. If apikey is a known secret key and the chain contains 'secret', route directly to secret regardless of Authorization. Same for publishable.
- Treat platform-issued
api-keys-jwt-issuer JWTs specially. When user mode encounters an Authorization JWT whose issuer is the API-keys issuer rather than auth/v1, treat it as "no user credential" and fall through to the next mode instead of rejecting.
- Document the runtime's header injection and the recommended workaround, ideally exposed as a built-in option on
withSupabase (e.g. stripPlatformAuth: true).
(1) feels cleanest because the apikey header is the actual signal of caller intent.
Environment
@supabase/server@^1.0.0
- Supabase Edge Functions (Deno runtime)
- New API keys (
sb_secret_* / sb_publishable_*) format active
Happy to PR if a direction is preferred.
Summary
When an Edge Function is configured with a multi-mode auth chain that includes both
'user'and'secret'(e.g.auth: ['user', 'secret']), the chain consistently rejects legitimatesecret-mode service-to-service calls because the Edge Functions runtime auto-injects anAuthorization: Bearer <jwt>header that the wrapper interprets as a (failed)user-mode attempt.Per the docs:
This is intentional, but combined with the runtime's automatic
Authorizationinjection onverify_jwt = falsefunctions it makes['user', 'secret']effectively unusable for callers that authenticate via theapikeyheader (e.g.pg_netfrom Postgres, orsupabaseAdmin.functions.invoke()from another function).After the rollout of new API keys (
sb_secret_*/sb_publishable_*), the injected JWT carries issuerapi.supabase.co/.../api-keys-jwt-issuer, which is not theauth/v1issuer expected byusermode — sousermode rejects withInvalidCredentialsErrorand the chain terminates beforesecretis evaluated. (We hit this concretely after toggling the project's vanity domain, which migrated our service_role key to the new format.)Reproduction
Call from another function with
supabaseAdmin.functions.invoke('my-fn', { body: {} }), or from Postgres viapg_netwith the secret key inapikey. The runtime addsAuthorization: Bearer <platform-jwt>,withSupabasetriesuserfirst, fails, terminates → 401, never reachessecret.Both legitimate callers (frontend with user JWT, service-to-service with secret key) cannot be served by the same function under the documented chain syntax.
Current workaround
Strip
Authorizationwhen theapikeyis a recognizable secret key, before handing the request towithSupabase:This is gated on
sb_secret_*so it never affects user traffic, and becomes a no-op if the runtime ever stops injecting the header. It works, but it's surprising that every consumer ofauth: ['user', 'secret']on Edge Functions has to know about and apply this.Proposed enhancements (any one would help)
apikeyfirst. InsidewithSupabase, when evaluating a multi-mode chain, inspectapikeybefore deciding which mode to attempt. Ifapikeyis a known secret key and the chain contains'secret', route directly tosecretregardless ofAuthorization. Same for publishable.api-keys-jwt-issuerJWTs specially. Whenusermode encounters anAuthorizationJWT whose issuer is the API-keys issuer rather thanauth/v1, treat it as "no user credential" and fall through to the next mode instead of rejecting.withSupabase(e.g.stripPlatformAuth: true).(1) feels cleanest because the
apikeyheader is the actual signal of caller intent.Environment
@supabase/server@^1.0.0sb_secret_*/sb_publishable_*) format activeHappy to PR if a direction is preferred.