Scoped API Keys

Named, revocable API keys limited to the session lifecycle — how to create, scope, rotate and audit them.

A CRHQ instance has one master API key with full access — that key belongs to the operator and should never live inside a product. External products use scoped keys: named, revocable credentials limited to exactly what an integration needs.

Creating a key

In the UI: Settings → API Keys → Create Key. Pick a name (use the product's name — it appears in audit trails) and the scopes. The key value (crhq_sk_…) is shown exactly once — copy it immediately. Only its hash is stored; if the value is lost, revoke the key and create a new one.

Via API (master key required):

curl -X POST https://<instance>/api/settings/api-keys \
  -H "x-api-key: $MASTER_KEY" -H "Content-Type: application/json" \
  -d '{"name": "myproduct", "scopes": ["sessions:write", "sessions:read"]}'
# → 201 { "success": true, "key": "crhq_sk_…", "id": "…", "name": "myproduct", … }

Scopes

ScopeGrants
sessions:writeStart turns, create sessions, send messages, rename or re-model a session, bind an agent, void queued messages
sessions:readRead a conversation, its metadata and queue; subscribe to its live WebSocket stream

Everything else — settings, credentials, provider auth, agent and skill management, listing all sessions, deleting sessions — stays master-key-only. A scoped key calling outside its scope gets a 403 naming the key and the missing scope; the master key's behavior is completely unchanged.

Using a key

Send it on every request, either way:

x-api-key: crhq_sk_…
Authorization: Bearer crhq_sk_…

For WebSocket streaming, send x-api-key as a header on the upgrade request. A key with sessions:read can subscribe to a session's stream; starting a turn over WebSocket requires sessions:write.

Managing keys

ActionHow
List keys (name, scopes, status, last used)Settings → API Keys, or GET /api/settings/api-keys
Edit name or scopesApplies immediately — the product does not need a new key
RevokeDeactivates the key; every call it makes returns 401 from that moment. Reversible
DeletePermanent. Any product using the key stops working immediately

last_used_at is tracked per key, and every session created by a scoped key is attributed to it — so you can always answer "which product did this?"

⚠️ What a scoped key really grants

A key with sessions:write can start agent turns, and an agent turn executes tools on the server. That is the point of the platform — the agent reads files, runs commands and calls skills to do its job. It also means the scope limits the HTTP surface, not what the agent can do once running.

Treat a write-scoped key as equivalent to access to that server, and issue it only to products you would trust at that level.

Pin every key to specific agents

This is the control that actually narrows the blast radius. When creating a key, choose "Only these agents" and select the purpose-built agent(s) your product needs:

curl -X POST https://<instance>/api/settings/api-keys \
  -H "x-api-key: $MASTER_KEY" -H "Content-Type: application/json" \
  -d '{"name":"myproduct","scopes":["sessions:write","sessions:read"],
       "allowedAgents":["myproduct-worker"]}'

The key can then only run that agent — with the instructions, skills and tools you gave it. Attempts to bind a different agent, or to start a turn on a session running one, return 403. A session created by a restricted key is bound to an allowed agent automatically, so it can never fall back to the default operator agent.

Keys created without an agent list (allowedAgents: null) may run any agent, including privileged ones. The UI marks these "Any agent ⚠".

Security model, honestly

  • Keys are stored as SHA-256 hashes and compared timing-safely; the plaintext exists only in your product's configuration.
  • A leaked scoped key exposes the session lifecycle on that instance over HTTP — not settings, credentials, or the operator surface. But if it carries sessions:write it can run agent turns; with an agent allowlist, only the agents you nominated. Revoke it in Settings and issue a new one.
  • On a same-host deployment (product and CRHQ on one server), the host itself is a single trust domain — a small set of localhost-only channels used by the platform's own agents are trusted on-host. Full network-level isolation comes with off-host deployment, which the scoped-key contract is designed for.
  • Optional per-product session isolation (API_KEY_OWNERSHIP_ENFORCE) locks each session to the key that created it — off by default on single-product instances.