Skip to content

Why hosts do not receive raw OAuth tokens

This is a real architectural rule, not a polish detail. It is the reason the pairing grant explicitly excludes provider OAuth tokens, and the reason system.factotum (the local credentials actor) is the one component allowed to read or write OAuth credentials on a Device.

This page states the rule, the threat model, the boundary between what HiveCast does and what the local Device does, and the four code-review consequences.

The rule

A pairing grant only contains device-scoped credentials. It MUST NOT contain the principal's OAuth refresh tokens, OAuth access tokens, or OIDC ID tokens for any third-party identity provider.

There are two distinct OAuth families in this product, and they live on opposite sides of the network:

OAuth familyWhere the redirect landsWhere the token is storedCrossings
HiveCast account login (Google, GitHub, Enterprise SSO)https://hivecast.ai/api/auth/callback/<provider>HiveCast system.auth credentials[] table, principal-scopedThe OIDC id_token is consumed cloud-side. Devices never see it.
Per-provider inference OAuth (Anthropic, Codex, OpenAI when interactive)http://localhost:<port>/callbackDevice-local: system.factotum writes <matrix-home>/credentials.jsonThe browser redirect lands on the Device; the token never leaves the Device.

The first family is registered with each provider. Google does not let you register http://localhost:8765/callback as a redirect URI for production apps; the redirect URI is https://hivecast.ai/..., registered once per provider. So the OIDC code exchange happens on HiveCast.

The second family is the opposite. Anthropic's OAuth flow expects a loopback redirect (http://localhost:<port>/callback) and the resulting refresh token is meant to be stored on the user's machine. Sending it to the cloud would (a) cross trust boundaries with no benefit and (b) create a massive blast radius if HiveCast were compromised.

The threat model

What does a Device get if you give it a principal-level OAuth token?

  • Persistent ability to act AS the principal against any service the principal is logged into via that provider
  • Refresh tokens that survive principal session expiry
  • Cross-product blast radius: a compromised Device could enumerate every service the principal has ever signed into via Google

What does a Device get with a device-scoped credential?

  • A NATS user JWT scoped to one principal × one Space × one authorityRoot, expiring in 24 hours
  • A heartbeat bearer token for one Host Link record, revocable independently
  • Capabilities limited to the scopes[] attached to that link

Per-Device blast radius. Per-Device revocation. The principal's broader identity and external-provider tokens stay where the user originally put them.

The boundary

WORKSTREAMS/matrix-web/USER-FLOWS-AND-AUTH-BOUNDARIES.md codifies the boundary as:

FlowMUST happenWhy
Google / GitHub / Enterprise SSO sign-inHiveCastRegistered redirect URIs
Email verificationHiveCastHiveCast sends + receives
Domain DNS verificationHiveCastDNS check + sign
Account creationHiveCastOwns the root registry
Anthropic OAuthLOCALLoopback redirect; token stays local
Codex / OpenAI OAuthLOCAL~/.codex/auth.json is local
Ollama configurationLOCALOllama runs locally
Inference API callsLOCALTokens are local; API calls go directly
Credential storageLOCAL<matrix-home>/credentials.json, never uploaded

The pairing grant is the bridge between those two columns. It carries only what is needed to talk to the cloud bus on this Device's behalf — nothing from the left column, nothing about other right-column credentials. The Device holds its own factotum store; the cloud holds its own principal-level credentials store. They never copy across.

Where this is enforced in code

Cloud side — the redeem response

auth.device.exchange and auth.pair.exchange produce a DeviceSetupExchangeResponse whose credentials map contains only:

  • natsUserJwt (device-scoped)
  • natsUserSeed (device-scoped nkey)
  • hostLinkToken (heartbeat bearer for this link)
  • registryToken (optional, device registry write token)

There is no code path that reads from HostCredentialStore.list() (the principal-level credentials store) and writes those values into the exchange response. If you ever see principal.credentials flowing into DeviceSetupExchangeResponse, that's a bug — file it as a security regression.

Device side — Factotum is the boundary

system.factotum is the one actor on a Device that reads or writes provider credential files. The op surface (declared in system-factotum/dist/index.js:222-340 after build) is:

credential.store
credential.get
credential.list             — never returns secrets, only metadata
credential.delete
credential.refresh
credential.import-detected  — discovers ~/.claude/.credentials.json, ~/.codex/auth.json, env vars
credential.import           — imports a previously detected source
credential.validate         — proxies a minimal API call to verify
credential.lease            — issues short-lived lease (NOT raw secret)
credential.materialize      — exchanges lease for HTTP headers (only callable by trusted system actors)

credential.lease / credential.materialize is the discipline that lets inference actors call third-party APIs without ever holding the raw secret. The actor calls credential.lease and gets back a leaseId, then hands that to the inference catalog which calls credential.materialize and gets HTTP headers ready to use. The raw key never crosses the bus.

Cloud-side counterpart

HostCredentialStore (in host-auth.ts:432-490) stores principal-level credentials cloud-side. Its store() and getNatsAccount() methods are how the cloud holds the principal's NATS account seed (used to mint device user JWTs). That account seed is never copied into a redeem response either; only its derived per-device user credentials are.

Four code-review consequences

When reviewing a PR that touches pairing, auth, or credentials, reject the following patterns even if "everything works":

  1. A pairing grant carrying a non-NATS bearer token. If you see googleAccessToken, anthropicRefreshToken, codexJwt, or anything similar in DeviceSetupExchangeResponse.credentials, that's a violation. Per-provider OAuth belongs in the local Factotum store on the Device, not in the pairing grant.

  2. An actor other than system.factotum reading <matrix-home>/credentials.json directly. If a chat actor, an inference catalog, or anything else opens that file, that's a violation. They go through credential.lease / credential.materialize instead.

  3. A pairing flow that "imports" provider tokens from the principal. If auth.device.exchange ever takes provider/accessToken inputs to forward to the Device, that is the wrong direction. Per-Device provider OAuth should be initiated from the Device's own browser via system.factotum's detect/import ops.

  4. HTTP headers including raw third-party tokens travelling on the bus. The lease/materialize pattern exists so the bus only ever carries lease IDs. Inference actors that call third-party APIs should only see fully formed headers: Record<string, string> from credential.materialize — never raw apiKey strings on bus payloads.

Mantra

The mantra to internalize is:

HiveCast credentials live cloud-side. Provider credentials live Device-side. The pairing grant is the bridge — it carries only Device-scoped, Host-Link-bound material in either direction.

If a feature seems to need raw OAuth tokens to cross the boundary, re-design the feature so it doesn't. There has not yet been a case where that re-design wasn't possible.

See also

Source: WORKSTREAMS/matrix-web/USER-FLOWS-AND-AUTH-BOUNDARIES.md § "Auth Boundary Rules"; WORKSTREAMS/product-launch/HIVECAST-DEVICE-ENROLLMENT-SPEC.md § "Correct Security Model" and § "Setup-exchange / device redeem response"; projects/matrix-3/packages/system-factotum/ (Factotum op surface and lease/materialize discipline); projects/matrix-3/packages/system-auth/src/host-auth.ts (no principal-token paths in HostLinkStore or HostDeviceLinkStore).