x-api-key header:
GET /v1/health, POST /v1/onboarding, GET /v1/availability)
take no key — see docs/api/openapi.yaml.
Key format
A key is the prefix plus 32 lowercase hex characters (128 bits of entropy).
Keys are shown once, at creation. Only a SHA-256 hash is stored — we cannot
recover a lost key, and neither can support. Lost it? Create a new one and revoke the
old.
wcrm_ deprecation
Legacy wcrm_ keys continue to authenticate and will not be switched off without
notice. Integrators cannot rotate on our schedule, and silently rejecting a working
key is an outage on their side, not ours.
New keys are always issued as f9_. To migrate: create a new key with the scopes you
need, deploy it, confirm traffic on the new key, then revoke the old one. No
coordinated cutover required — both families work simultaneously.
Scopes
Scopes use the samemodule:action model as in-app permissions (leads:view,
customers:create, and object:<slug>:create for tenant-defined objects). One model
means a key can never quietly exceed what the equivalent role could do.
Legacy scopes still authorise. A key issued with leads:read satisfies a
leads:view requirement, and vice versa — the bridge is in
supabase/functions/_shared/scope-vocab.ts.
The mapping is deliberately least privilege — a legacy scope grants only what it
authorised at migration time:
leads:write maps to create only, on purpose. Mapping it to edit as well would
silently grant edit rights the moment an edit route ships — privilege escalation via a
lookup table is not a migration.
Wildcards still work: * grants everything, leads:* grants every action on leads.
Failure responses
Every failure uses the standard envelope — seedocs/api/errors.md.
⚠️ Behaviour change. Revoked and expired keys previously returned 403; they now return 401. The credential itself is no longer valid, which is an authentication failure — a 403 sends integrators to check their scopes when the real problem is the key. If your error handling branches on the status, treat 401 as “re-authenticate / rotate the key”.The 401/403 split is the useful signal: 401 means fix the key, 403 means fix the key’s permissions (or the tenant’s plan).
MODULE_DISABLED is not a scope problem — the key has the scope, but the tenant has
that product module switched off. Grants stay inert and restore automatically when the
module is re-enabled, so don’t delete the key.
Lifecycle
- Expiry — optional
expires_at. After it passes the key returns 401; nothing is deleted, so you can inspect it in Settings › API Keys. - Revocation — immediate and irreversible.
revoked_at,revoked_byandrevoke_reasonare recorded for the audit trail. - Usage tracking —
last_used_atandtotal_requestsare updated by a single atomic statement (increment_api_key_usage). The counter is incremented in the database rather than read-modify-written by the edge function, which previously lost increments whenever requests overlapped;last_used_atis refreshed at most once every 5 minutes rather than on every request.
Notes for the docs’ own accuracy
The acceptance criteria for this story nameAUTH_INVALID_KEY and FORBIDDEN_SCOPE.
Those codes do not exist — the real ones are AUTH_INVALID_KEY and
FORBIDDEN_SCOPE, which are what the API returns and what the frozen catalogue in
docs/api/openapi.yaml documents (enforced by src/test/api/error-catalogue.test.ts).
The catalogue wins; the AC prose was written before it was frozen.
OAuth 2.1 Bearer tokens (Epic 4 — )
The API accepts a second credential type alongsidex-api-key: an OAuth 2.1
access token in Authorization: Bearer <jwt>. This is what MCP clients and
third-party apps use. Once authenticated, a token behaves identically to an
API key — same ApiContext, same scope checks, same tenant isolation, same
403/404 — because both resolve to the same AuthenticatedKey shape before any
route runs. Services never learn which credential type was used.
Selection: if x-api-key is present it wins; otherwise a Bearer token is
tried; a request with neither is 401 with a WWW-Authenticate: Bearer header.
Validation (_shared/auth-provider.ts, issuer-agnostic so the provider is a
config swap):
- signature verified against the issuer’s JWKS (
ES256/RS256), with kid-rotation handled by a cached resolver; issmust equal the configured issuer;audis checked only whenOAUTH_EXPECTED_AUDIENCEis set;exp/nbfwithin a ±60s clock skew.- Any failure is
401withWWW-Authenticate— never 403 (an invalid credential is an authentication failure, matching the API-key rule above).
sub (the Supabase
user id == User.id) — never from the request. The actor id is
oauth_<client_id|sub> (not an api_key UUID), so OAuth calls are logged with a
null api_key_id.
Scopes: standard OIDC scopes (openid/profile/…) are dropped; any
module:action scopes the token carries are honoured. Per the spike the
Supabase beta issues OIDC-only scopes, so a token’s permissions come from the
tenant’s grant store, resolved server-side by the token proves identity,
not authority.
Config (env): OAUTH_ISSUER (defaults to ${SUPABASE_URL}/auth/v1),
OAUTH_JWKS_URI (defaults to the issuer’s /.well-known/jwks.json),
OAUTH_EXPECTED_AUDIENCE (optional).