MCP

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_URL unset), 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). Set HANDOFF_SYNC_SECRET to require a bearer token even in workspace mode.
  • Registry mode (DATABASE_URL set), a bearer token is required. An unauthenticated request gets 401 with an RFC 9728 WWW-Authenticate header pointing a client at protected- resource metadata (see below), rather than a bare rejection.

A valid bearer is either:

  1. The legacy shared secret (HANDOFF_SYNC_SECRET), grants every scope, isLegacySecret: true. Meant for automation, not interactive clients.
  2. An OAuth 2.1 JWT with a scp claim (space-separated scopes) and an audience of handoff-api (or the legacy handoff-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.

  1. POST /api/oauth/device{ device_code, user_code, verification_uri, verification_uri_complete }
  2. The CLI opens (or prints) the verification URL; the user approves the request in a browser at /cli/device.
  3. The CLI polls POST /api/oauth/token with grant_type=urn:ietf:params:oauth:grant-type:device_code and the device_code until the user approves, then gets back { access_token, token_type, expires_in }.
  4. 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:

  1. The client discovers endpoints via RFC 8414 Authorization Server Metadata (/.well-known/oauth-authorization-server) and, if it first hit /api/mcp unauthenticated, the 401's WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource" header (RFC 9728).
  2. The client registers itself via RFC 7591 Dynamic Client Registration (POST /api/oauth/register), no manual client setup on Handoff's side.
  3. The user is redirected through /api/oauth/authorize (response type code, code_challenge_method=S256) and approves.
  4. The client exchanges the code at POST /api/oauth/token (grant_type=authorization_code, with code_verifier per PKCE) for { access_token, refresh_token, token_type, expires_in, scope }.
  5. refresh_token grants renew access the same way (grant_type=refresh_token).

Scopes

The full scope vocabulary (oauth-scopes.ts), shared by both flows:

ScopeCovers
sync:readChecked by handoff_list_review_queue. handoff_sync_status and handoff_sync_pull read sync state but perform no scope check of their own.
sync:writePush sync changes, write pages/previews/doc pages
reference:readDesign guidelines, brand voice
components:readNot yet checked by any MCP tool or route — reserved in the vocabulary
components:writeNot yet checked by any MCP tool or route — reserved
design:readComponent reference images
design:writeNot yet checked by any MCP tool or route — reserved (granted to member-role tokens by default)
generate:componentNot yet checked by any MCP tool or route — reserved
figma:syncUsed 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.json in the workspace by runCliLogin(); read back by getSyncBearerToken(), reused by mcp-token and mcp-init without re-prompting until it expires.
  • MCP client config: varies by client, see Onboarding for how mcp-init wires the token into .mcp.json, and how OAuth connectors handle it automatically.

On this page