Authentication
How MCP clients authenticate against a Handoff registry.
Handoff's MCP endpoint (/api/mcp) and its reference API share one auth layer,
verifyHandoffApiAuth(). Whether it requires anything at all depends on the run mode.
Workspace vs. registry
- Workspace mode (
DATABASE_URLunset), MCP is unauthenticated by default: any local client gets a read-only workspace auth context (admin role,sync:read reference:read components:read design:read). SetHANDOFF_SYNC_SECRETto require a bearer token even in workspace mode. - Registry mode (
DATABASE_URLset), a bearer token is required. An unauthenticated request gets401with an RFC 9728WWW-Authenticateheader pointing a client at protected- resource metadata (see below), rather than a bare rejection.
A valid bearer is either:
- The legacy shared secret (
HANDOFF_SYNC_SECRET), grants every scope,isLegacySecret: true. Meant for automation, not interactive clients. - An OAuth 2.1 JWT with a
scpclaim (space-separated scopes) and an audience ofhandoff-api(or the legacyhandoff-cli-sync).
There are two distinct ways to obtain that JWT, for two different kinds of client.
1. CLI / device-code flow (RFC 8628)
Used by handoff-app login, mcp-init, and mcp-token: anywhere a human is at a terminal,
not a browser-based OAuth redirect.
POST /api/oauth/device→{ device_code, user_code, verification_uri, verification_uri_complete }- The CLI opens (or prints) the verification URL; the user approves the request in a browser
at
/cli/device. - The CLI polls
POST /api/oauth/tokenwithgrant_type=urn:ietf:params:oauth:grant-type:device_codeand thedevice_codeuntil the user approves, then gets back{ access_token, token_type, expires_in }. - That token is used as
Authorization: Bearer <access_token>on every subsequent MCP/API request.
2. MCP OAuth connector flow (OAuth 2.1 authorization code + PKCE)
Used by clients that register themselves dynamically and drive a normal OAuth browser redirect, e.g. a claude.ai Connector or Claude Desktop's connector UI:
- The client discovers endpoints via RFC 8414 Authorization Server Metadata
(
/.well-known/oauth-authorization-server) and, if it first hit/api/mcpunauthenticated, the401'sWWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource"header (RFC 9728). - The client registers itself via RFC 7591 Dynamic Client Registration
(
POST /api/oauth/register), no manual client setup on Handoff's side. - The user is redirected through
/api/oauth/authorize(response typecode,code_challenge_method=S256) and approves. - The client exchanges the code at
POST /api/oauth/token(grant_type=authorization_code, withcode_verifierper PKCE) for{ access_token, refresh_token, token_type, expires_in, scope }. refresh_tokengrants renew access the same way (grant_type=refresh_token).
Scopes
The full scope vocabulary (oauth-scopes.ts), shared by both flows:
| Scope | Covers |
|---|---|
sync:read | Checked by handoff_list_review_queue. handoff_sync_status and handoff_sync_pull read sync state but perform no scope check of their own. |
sync:write | Push sync changes, write pages/previews/doc pages |
reference:read | Design guidelines, brand voice |
components:read | Not yet checked by any MCP tool or route — reserved in the vocabulary |
components:write | Not yet checked by any MCP tool or route — reserved |
design:read | Component reference images |
design:write | Not yet checked by any MCP tool or route — reserved (granted to member-role tokens by default) |
generate:component | Not yet checked by any MCP tool or route — reserved |
figma:sync | Used by the Figma plugin's REST endpoints (/api/figma-plugin/*), not by any MCP tool |
Role → scope defaults (scopesForRole): an admin device/connector login receives every
scope above; a member receives sync:read reference:read components:read design:read design:write.
Most read tools have no per-tool scope check
Only a handful of tools call an explicit scope check, see the Scope column on the Tool catalog page. Everything else is gated purely by whether the caller has a valid bearer at all (registry mode) or by workspace mode being open by default.
Where the token is stored
- CLI: written to
.handoff/cli-auth.jsonin the workspace byrunCliLogin(); read back bygetSyncBearerToken(), reused bymcp-tokenandmcp-initwithout re-prompting until it expires. - MCP client config: varies by client, see Onboarding for how
mcp-initwires the token into.mcp.json, and how OAuth connectors handle it automatically.