# 0009: Exo Key headless sign-in — device authorization grant and personal Exo Keys

https://exo.now/docs/adr/0009-exo-key-headless-sign-in

> Status: proposed · Date: 2026-10-07



# ADR 0009: Exo Key headless sign-in — device authorization grant and personal Exo Keys [#adr-0009-exo-key-headless-sign-in--device-authorization-grant-and-personal-exo-keys]

* Status: proposed
* Date: 2026-10-07
* Extends: [0004-exo-id-central-identity-provider.md](0004-exo-id-central-identity-provider.md),
  ADR 0008 (Exo Platform API, Exo Key API tokens; PR #145)

## Context [#context]

Owner decision: Construct and Exo must be usable headlessly from agents (Hermes, Claude Code,
Codex, T3 Code) through MCP servers and CLIs. Sign-in lives only at Exo (Exo Key); apps trust
Exo-issued RS256 JWTs (`aud` = app). ADR 0004 covers browser apps (authorization code + PKCE with
registered redirect URIs). A CLI or agent has no redirect URI it can register, and a script or
server must not hold a person's browser session.

## Decision [#decision]

Exo Key adds two headless paths. Both end in the same access token apps already verify (issuer
`${EXO_CONVEX_SITE}/exo-id`, `aud` = app, RS256, 15 minutes), plus a `scope` claim.

### 1. Device authorization grant (RFC 8628), for interactive CLIs and agents [#1-device-authorization-grant-rfc-8628-for-interactive-clis-and-agents]

* `POST {prefix}/v1/device/code` with `{app, scope?}` (JSON or form) returns `device_code`,
  `user_code` (8 characters `XXXX-XXXX` from the RFC 8628 §6.1 consonant alphabet
  `BCDFGHJKLMNPQRSTVWXZ`), `verification_uri` (`https://<exo>/id/device`),
  `verification_uri_complete` (`…?code=XXXX-XXXX`), `expires_in` 600 and `interval` 5.
* The person opens `/id/device` on the Exo frontend, signs in to Exo if needed (returning to the
  same URL), sees the app and requested scope, and approves or denies. Approval binds the grant
  to the signed-in Exo user.
* The CLI polls `POST {prefix}/v1/token` with
  `grant_type=urn:ietf:params:oauth:grant-type:device_code`, `device_code`, `app`. Errors are
  RFC 8628 §3.5: `authorization_pending`, `slow_down` (with the new `interval`; each one adds 5 s),
  `access_denied`, `expired_token`; unknown/redeemed codes or the wrong app give `invalid_grant`.
  An approved grant returns the same body as the authorization-code grant (access token, rotating
  refresh token, user) and is single-use.
* The granted `scope` is stored on the refresh-token family and carried to every refreshed
  access token.

### 2. Personal Exo Keys + token exchange (RFC 8693), for scripts, servers and unattended agents [#2-personal-exo-keys--token-exchange-rfc-8693-for-scripts-servers-and-unattended-agents]

* A signed-in Exo user creates, lists and revokes their own Exo Keys (`exoKeyTokens.createMine`,
  `listMine`, `revokeMine`; UI at `/id/keys`). They reuse the ADR 0008 phase 1 table and format
  (`exo_tk_<prefix>_<secret>`, SHA-256 at rest, prefix as the public handle, 1–365 day TTL) and
  are owned by the user (`userId`) under the user's personal tenant `user-<userId>`. The secret is
  returned once. Up to 10 live keys per user; creation is rate-limited per user.
* Scopes: `read` (default), `write`, `ai`, `usage:read`. Apps define what `read`/`write` permit;
  `ai`/`usage:read` keep their platform-API meaning.
* A key is bound to one app or to all headless apps.
* `POST {prefix}/v1/token` with `grant_type=urn:ietf:params:oauth:grant-type:token-exchange`,
  `subject_token=<key>`, `subject_token_type=urn:exo:token-type:exo-key`, `app`, and optional
  `scope` (must be a subset of the key's scopes) returns a 15-minute access token (`aud` = app,
  `sub` = key owner, `scope` claim), `issued_token_type`
  `urn:ietf:params:oauth:token-type:access_token`, and **no refresh token**: the key is the
  long-lived credential and every exchange re-checks it. Errors: `invalid_grant` (unknown,
  revoked, expired, or an operator tenant token with no user), `invalid_target` (the key is bound
  to another app, or the app has not opted in), `invalid_scope`.
* Operator-minted tenant tokens (ADR 0008, no `userId`) are never exchangeable: they identify a
  tenant, not a person.

### Registry opt-in [#registry-opt-in]

`convex/lib/exoIdApps.ts` gains `label` and `deviceFlow`. Only apps with `deviceFlow: true`
accept device grants and app-bound personal keys. Enabled: `construct`, and `exo` (new; Exo's own
API and CLI, `aud` `exo`, no redirect URIs so the browser flow can never target it).

### Security [#security]

* `device_code` and Exo Keys are stored only as SHA-256. Comparisons happen through hash-index
  lookups; the existing constant-time compare covers PKCE and refresh successors.
* User codes are short, so they are only resolvable by a signed-in Exo user, through a
  rate-limited mutation (10/min per user). That is about 2.6e10 codes against at most a
  handful of pending grants per 10 minutes. A decided or expired code no longer resolves.
* `device/code` is rate-limited per client IP (rightmost `X-Forwarded-For`, as for `/v1/token`),
  and every token request still hits the existing per-IP limit. Exchange is additionally limited
  per key.
* The consent screen tells the person to approve only a code they started.

## Consequences [#consequences]

* Agents get two documented options: device flow for a person at a terminal, an Exo Key for
  unattended use. Neither needs a redirect URI or a browser cookie.
* Apps change nothing to accept these tokens. To act on `scope`, they read the `scope` claim.
* The `/id/device` and `/id/keys` pages ship with the Exo frontend. `verification_uri` is derived
  from `EXO_ID_AUTHORIZE_URL` (override: `EXO_KEY_DEVICE_URL`).
* Follow-ups: an `exo login` CLI and MCP helper on top of the device flow; per-app scope
  catalogs; showing active device sessions on `/id/keys`.
