API

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

TypeHeaderUsed by
Sync bearerAuthorization: Bearer <token>CLI push/push:all/pull; either the raw HANDOFF_SYNC_SECRET value or a CLI OAuth JWT.
Session cookieNextAuth session (__Secure-next-auth.session-token)Browser UI — every /api/handoff/* CRUD/admin/AI route.
OAuth JWTAuthorization: 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-only admin-role context (sync:read reference:read components:read design:read). Set HANDOFF_SYNC_SECRET to require a bearer even here.
  • Registry mode (DATABASE_URL set), a bearer is required; an unauthenticated request gets 401 with an RFC 9728 WWW-Authenticate header 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:

ScopeCovers
sync:readRead sync cursor/changes.
sync:writePush sync changes; the only scope /api/registry/* and /api/sync/upload check.
reference:readDesign guidelines, brand voice (/api/handoff/reference-materials).
components:readReserved — not yet checked by any route.
components:writeReserved — not yet checked by any route.
design:readComponent reference images.
design:writeReserved — not yet checked, but granted to member-role tokens by default.
generate:componentReserved — not yet checked by any route.
figma:syncUsed 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.

On this page