* docs: clarify bare auth mode matches only the default key Bare `auth: 'publishable'` / `auth: 'secret'` match only the `default` key in SUPABASE_PUBLISHABLE_KEYS / SUPABASE_SECRET_KEYS. This was documented inconsistently — two spots wrongly claimed bare `secret` accepts any key, and most tables/TSDoc omitted the detail entirely. - Fix incorrect "accepts any secret key" statements in security.md and the supabase-server skill (bare = default; `secret:*` = any) - Name the `default` key directly in every auth-mode table (README, auth-modes.md, security.md, src/index.ts TSDoc) - Add the default-key sentence to the Secret mode section to match Publishable, and clarify AuthMode / AuthModeWithKey TSDoc in types.ts * docs: document default-key resolution for wildcard and client factories Follow-up to the auth-mode default-key pass, closing two remaining gaps: - Publishable mode section now mentions the `publishable:*` wildcard, so it mirrors the Secret mode sentence exactly. - Document which key createAdminClient / createContextClient use: the named key when `auth.keyName` is set, otherwise `default` with a fallback to the first key in the set. This was untouched by docs anywhere despite being tested behavior, and it deliberately differs from the `secret` / `publishable` auth modes, which match `default` only and never fall back.
5.9 KiB
Security
This document explains the security decisions behind @supabase/server. It's informational — you don't need to read this to use the package, but it helps if you want to understand why things work the way they do.
Timing-safe credential comparison
API keys are compared using constant-time comparison to prevent timing attacks.
A naive string comparison (===) short-circuits on the first mismatched character. An attacker can measure response times to guess the key one character at a time. With enough requests, this leaks the full key.
The package uses a double-HMAC technique: both strings are HMAC'd with a random ephemeral key, then the resulting digests are compared byte-by-byte with a constant-time XOR loop. This ensures that comparison time is independent of where (or whether) the strings differ.
This applies to:
- Publishable key verification (
auth: 'publishable') — compares theapikeyheader against stored publishable keys - Secret key verification (
auth: 'secret') — compares theapikeyheader against stored secret keys
See src/core/utils/timing-safe-equal.ts for the implementation.
Auth mode security model
Each auth mode provides a different level of trust:
| Mode | What it verifies | Who the caller is | supabase client |
supabaseAdmin client |
|---|---|---|---|---|
user |
JWT signature against JWKS | An authenticated user | Row-Level Security | Full access |
publishable |
default publishable key (timing-safe) |
A known client app | Row-Level Security | Full access |
secret |
default secret key (timing-safe) |
A trusted server/service | Full access | Full access |
none |
Nothing — all requests are accepted | Unknown | Row-Level Security | Full access |
For publishable and secret, the bare mode matches only the default key; use publishable:<name> / secret:<name> for a specific key or publishable:* / secret:* to accept any key in the set.
Key implications:
usermode verifies the JWT using a local JWKS (JSON Web Key Set). The token must contain asubclaim. Verification uses thejoselibrary'sjwtVerifywith a local key set — no network calls to an auth server.publishableandsecretmodes compare theapikeyheader against known keys. The comparison is timing-safe. Bareauth: 'publishable'/auth: 'secret'match only thedefaultkey; use named keys (auth: 'secret:automations') to accept only that specific key — this follows the principle of least privilege — or the wildcard (auth: 'secret:*') to accept any key in the set.nonemode performs zero authentication. The handler runs for every request. ThesupabaseAdminclient is still available, so a compromisednoneendpoint with write operations is a security risk. Only use it for truly public endpoints or when you implement your own auth (e.g., webhook signature verification).
Named key isolation
Bare auth: 'secret' matches only the default key. You can restrict an endpoint to a specific named key, or accept any key in the set with the wildcard:
// Matches only the "default" secret key
withSupabase({ auth: 'secret' }, handler)
// Only accepts the "automations" secret key
withSupabase({ auth: 'secret:automations' }, handler)
// Accepts any secret key in the set
withSupabase({ auth: 'secret:*' }, handler)
This limits the blast radius if a key is compromised. An attacker with the web publishable key cannot access an endpoint that requires secret:automations. Named keys also make it easier to rotate or revoke access for a specific consumer without affecting others.
JWT verification
JWT verification in user mode works as follows:
- The
Authorization: Bearer <token>header is extracted from the request - The token is verified against the JWKS from the
SUPABASE_JWKSenvironment variable - Verification uses
jose'sjwtVerifywith a local key set — there are no network calls to a JWKS endpoint - The token must contain a
sub(subject) claim to be considered valid - On success, the decoded claims are available as
ctx.userClaimsandctx.jwtClaims
If JWKS is not configured (SUPABASE_JWKS is missing or malformed), user mode is unavailable and will always reject requests.
No silent downgrade. When user is combined with other modes (e.g. auth: ['user', 'publishable']), a JWT that is present but fails verification rejects the request with InvalidCredentialsError — it does not fall through to the next mode. This prevents a bad token paired with a valid apikey (or with 'none') from being silently downgraded to a less-privileged auth mode. Requests that simply omit the Authorization header still fall through as expected.
CORS handling
withSupabase handles CORS automatically:
- Preflight requests (
OPTIONS) return204with CORS headers and skip the handler entirely — no auth check runs - All other requests get CORS headers appended to the response
- Error responses (auth failures) also include CORS headers, so the browser can read the error
CORS defaults come from @supabase/supabase-js/cors. You can pass custom headers or disable CORS entirely with cors: false.
The Hono adapter does not handle CORS — use Hono's built-in cors middleware instead.
Credential extraction
Credentials are extracted from two standard headers:
Authorization: Bearer <token>→ used byusermodeapikey: <value>→ used bypublishableandsecretmodes
Extraction is a separate step from verification (extractCredentials vs verifyCredentials). This separation means you can inspect raw credentials in custom flows without triggering validation.