Skip to content

withSupabase auth chain: platform-injected Authorization header breaks auth: ['user', 'secret'] for service-to-service callers #59

@EliaTolin

Description

@EliaTolin

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)

  1. 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.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions