Authentication
Which scheme a route accepts is the route, not a choice.
Two authentication schemes, split by surface
Which scheme a route accepts is a property of the route, not a choice the caller makes.
/api/v1/*: API key only. The programmatic surface:Authorization: Bearer sk_live_…, scope-authorized, per-key rate limited. Eight read endpoints, one per:readscope. No writes: the:writescopes exist so the read/write split was in the model from the start rather than retrofitted onto keys already issued.- Everything else: session cookie only. The surface the dashboard itself calls. No rate limiting of any kind (see the note on rate limiting below), and no CORS policy, so it is same-origin in practice.
/api/cron/*: a bearer secret. One route, invoked by the scheduler.
A session cookie will not authenticate an /api/v1 route, and an API key will not authenticate
anything else, deliberately including key management itself, so a leaked key cannot mint another.
Scheme status
Two authentication schemes coexist, and which one a route accepts is not a preference; it is the route. The dashboard's own surface (everything not under /api/v1) is session-cookie only and has no rate limiting. The programmatic surface is /api/v1/* and is API-key only: eight read endpoints, one for every :read scope in the vocabulary, all paged the same way and all camelCase. A session cookie will not authenticate an /api/v1 route and an API key will not authenticate anything else; notably not key management itself, which is why a leaked key cannot mint another. Full contract: docs/api-keys.md.
Schemes
sessionCookie
apiKey in cookie: sb-qxrvgfkjyvbngipqvslu-auth-token
The dashboard's Supabase Auth session cookie, set at sign-in. Large sessions are split across
numbered chunks (…auth-token.0, .1), so treat this as a cookie family rather than one name.
Every request re-validates it against the Auth server (getUser()), never by decoding the cookie
locally: a JWT nothing has checked is not a credential. Tenancy is then read from the verified
app_metadata.company_id claim and enforced by row-level security; it is never read from request
input, on any route, ever.
role (admin / staff) is deliberately not in RLS. It gates specific actions in route code,
the operations marked admin below, so hiding a button in the UI is cosmetic only, and a route's
own check is the enforcement.
cronBearer
http, bearer
The CRON_SECRET value, sent as Authorization: Bearer <secret>. Used by exactly one route. Fails closed when the secret is unset.
applePassAuth
http, ApplePass
Apple Wallet's own Web Service protocol header, Authorization: ApplePass <authenticationToken> (not a registered IANA scheme, but the literal token Apple's own iOS PassKit sends). Checked against book_loyalty_members.authentication_token (migration 0160) for the row the URL's serialNumber names, via timingSafeEqual (src/lib/loyalty/apple-web-service-auth.ts). A phone talking to these routes has no Supabase session and never will; this header is the entire gate.
bearerApiKey
http, bearer
Authorization: Bearer sk_live_<16 hex key id>_<43 char base64url secret>The scheme for programmatic callers: partner integrations, a customer's own scripts, the planned
skill and MCP server. Accepted by the /api/v1/* operations and by nothing else; the rest of this
document is session-cookie only. Every :read scope below has a GET /api/v1/<resource> behind it, and
a static check fails the build if a scope is added without one. Full detail lives in docs/api-keys.md; the load-bearing
parts are:
- Transport.
Authorization: Bearer sk_live_…only. No query parameter, no custom header, no cookie fallback: a credential in a URL ends up in access logs, referrers and browser history. - A key is a program with scopes, not a human with a role. It carries no
admin/staffrole at all; scopes are the entire authorization model, and an admin issuing a key is deliberately delegating their own authority. - Scopes are
resource:action, where resource is exactly the/api/<resource>folder name and action isreadorwrite.writedoes NOT implyread: they are compared by exact string. - The engine boundary is enforced twice. Engine scopes are filtered at issuance against the org's
own
business_type(an appointments org cannot be grantedreservations:*), and again at use time by the gate's vertical argument.wrong_verticalis its own 403 code. - There is no scope for key management. A key can never mint, list or revoke another key, so a
leaked key cannot be used to establish persistence.
organizationis read-only for the same reason migration 0022 exists. - Errors carry a machine-readable
codealongsideerror:missing_authorization,invalid_key,key_revoked,key_expired,organization_inactive(all 401);insufficient_scope,wrong_vertical,plan_required(403);rate_limited(429). Match oncode, never on the sentence. Note this is a RICHER error body than the{error}every session-authenticated route in this document returns. - Rate limiting is per key, a fixed 60-second window defaulting to 120 requests/minute, with
Retry-AfterandX-RateLimit-*headers on a 429. Session-authenticated routes have no rate limiting in application code, deliberately: the rest of the app is covered at the edge by a Vercel WAF rate-limit rule, which is not billed for what it blocks and never reaches a function. Seedocs/api-keys.md. - One envelope, one paging scheme. Every list answers
{ data: [...], nextCursor }; the singleton answers{ data: {...} }.nextCursoris keyset (seek) paging, opaque, and null exactly when there are no more rows. Every field is camelCase, unlike the database columns underneath. /api/v1/is a real version segment, not decoration. There is nov2yet. When one ships,v1keeps answering unchanged for a notice window of at least 6 months, and everyv1response carriesDeprecationandSunsetheaders (RFC 9745, RFC 8594) for that whole window. Only a security fix can shorten it. Seedocs/api-keys.md.