Auth & Scopes
How API requests are authenticated, and what scopes gate which routes.
Three credential types cover the whole API, but they're checked by two different code paths
depending on the cluster, worth understanding, because it explains why some routes only care
about one scope (sync:write) while others enforce the full vocabulary.
The three credential types
| Type | Header | Used by |
|---|---|---|
| Sync bearer | Authorization: Bearer <token> | CLI push/push:all/pull; either the raw HANDOFF_SYNC_SECRET value or a CLI OAuth JWT. |
| Session cookie | NextAuth session (__Secure-next-auth.session-token) | Browser UI — every /api/handoff/* CRUD/admin/AI route. |
| OAuth JWT | Authorization: Bearer <jwt> | MCP clients and the reference-materials API; issued by either OAuth flow in /api/oauth, audience handoff-api (or the legacy handoff-cli-sync). |
openapi.yaml also lists an X-Handoff-Api-Key header, not implemented
The spec's security-scheme section documents a third header, X-Handoff-Api-Key, for MCP and
machine-to-machine calls. The actual auth code (verifyHandoffApiAuth()) only ever parses an
Authorization: Bearer header; there's no X-Handoff-Api-Key check anywhere in the codebase.
Use a bearer token for MCP, not that header.
Two auth code paths
/api/registry/* and /api/sync/* use verifySyncAuth() / verifySyncBearer()
(src/app/lib/sync-auth.ts): accept the legacy HANDOFF_SYNC_SECRET bearer, or any valid CLI
JWT. Write endpoints (push upload, every /api/registry/* POST) additionally require the
JWT's scp claim to include sync:write; that's the only scope this path checks.
/api/mcp and the reference-materials API (/api/handoff/reference-materials, the Figma
plugin's foundations routes) use verifyHandoffApiAuth() (src/app/lib/mcp-auth.ts), which
supports per-route requireScopes checks against the full vocabulary below, and additionally
branches on run mode:
- Workspace mode (no
DATABASE_URL), unauthenticated by default, granted a read-onlyadmin-role context (sync:read reference:read components:read design:read). SetHANDOFF_SYNC_SECRETto require a bearer even here. - Registry mode (
DATABASE_URLset), a bearer is required; an unauthenticated request gets401with an RFC 9728WWW-Authenticateheader pointing at protected-resource metadata, rather than a bare rejection.
Session-cookie auth for the registry's own stakeholder/admin UI (everything else under
/api/handoff/*) is separate from both of the above, see Registry → Auth.
Scope vocabulary
Shared by both OAuth flows and by verifyHandoffApiAuth()'s requireScopes checks:
| Scope | Covers |
|---|---|
sync:read | Read sync cursor/changes. |
sync:write | Push sync changes; the only scope /api/registry/* and /api/sync/upload check. |
reference:read | Design guidelines, brand voice (/api/handoff/reference-materials). |
components:read | Reserved — not yet checked by any route. |
components:write | Reserved — not yet checked by any route. |
design:read | Component reference images. |
design:write | Reserved — not yet checked, but granted to member-role tokens by default. |
generate:component | Reserved — not yet checked by any route. |
figma:sync | Used by the Figma plugin's own REST endpoints (/api/figma-plugin/*), not by /api/mcp or any cluster in this pillar. |
Role → scope defaults (scopesForRole()): an admin login receives every scope above; a
member receives sync:read reference:read components:read design:read design:write.
See MCP → Authentication for the full walk-through of both OAuth flows that
issue these JWTs, and /api/oauth for the endpoints themselves.