Keys & scopes
A Titen credential is a hashed bearer token carrying a scope list and a trust ceiling, and neither can be widened by the key that mints the next one.
What a key actually is
A raw key is titen_sk_ plus 43 base64url characters, 32 bytes from
crypto.getRandomValues. Only its SHA-256 hash reaches storage, so a database copy cannot be
replayed as a credential. Verification is one indexed lookup on that hash and nothing about
the principal is cached, so revocation takes effect on the next request.
The row behind a key holds six things:
org_id the tenant. Never read from a request body.
principal_id the actor recorded as actor_id on the rows it writes.
principal_kind human, agent or service.
label a human name, so revoking the right key is not guesswork.
scopes a space-separated list, or * for all of them.
max_trust the ceiling on evidence this key may assert.
Tenant and actor authority always come from the credential. A valid identifier belonging to
another tenant returns the same non-disclosing 404 NOT_FOUND used for every cross-scope
denial.
The 34 scopes
Every route except /healthz and /readyz requires exactly one of these. A key holding *
satisfies all of them.
| Scope | Opens |
|---|---|
projects:resolve |
POST /v1/projects/resolve |
projects:create |
the create: true branch of that route; without it an unknown reference is a 404 |
observations:write |
POST /v1/observations |
claims:write |
POST /v1/consolidations, and claim supersede, revoke, expire |
context:compile |
POST /v1/context/compile |
feedback:write |
POST /v1/context/:id/feedback |
evidence:read |
GET /v1/claims/:id/evidence |
checkpoints:read |
GET /v1/checkpoints |
checkpoints:write |
POST /v1/checkpoints, DELETE /v1/checkpoints/:id |
workspaces:read |
GET /v1/workspaces |
workspaces:write |
POST /v1/workspaces |
memberships:read |
GET /v1/memberships |
memberships:write |
POST /v1/memberships, DELETE /v1/memberships/:id |
leases:write |
POST /v1/leases, DELETE /v1/leases/:id |
handoffs:read |
GET /v1/handoffs |
handoffs:write |
POST /v1/handoffs, POST /v1/handoffs/:id/resolve |
mcp:call |
POST /mcp — all seven tools |
events:read |
GET /v1/events, GET /v1/events/:id |
views:compile |
POST /v1/memory-views/compile |
policies:read |
GET /v1/policies |
policies:write |
POST /v1/policies |
releases:read |
GET /v1/channel-releases/:id |
releases:write |
POST /v1/channel-releases, /publish, /revoke |
channel:read |
POST /v1/channel-context |
audit:read |
GET /v1/audit |
audit:export |
GET /v1/audit/export |
federation:read |
GET peers, peer filters, federation log |
federation:write |
peer create, suspend, filters, pull, push |
webhooks:read |
GET /v1/webhooks, GET /v1/webhooks/:id/deliveries |
webhooks:write |
webhook create, delete, pause, resume, deliver |
index:write |
POST /v1/index/drain |
keys:manage |
POST /v1/keys, GET /v1/keys, DELETE /v1/keys/:id |
export:read |
GET /v1/export |
import:write |
POST /v1/import |
audit:export is separate from audit:read because pulling the trail out as NDJSON is a
different act from paging it as JSON; webhooks:read without webhooks:write is the shape a
monitoring job wants. A missing scope is 403 FORBIDDEN and names what was needed:
Missing required scope "claims:write".
Trust ceilings
Four levels, ranked: unverified (0) < asserted (1) < verified (2) <
policy_approved (3). max_trust is a ceiling on what a credential may assert. Ask for
more and the write fails before it touches anything:
{ "error": { "code": "FORBIDDEN",
"message": "This credential may not assert \"verified\" trust." },
"meta": { "request_id": "req_ec1a13fdae074509908eac6d5a62e7ee" } }
A claim’s trust may not exceed the trust of its supporting evidence, and consolidation checks
the ceiling again, so a low-ceiling agent cannot launder its own output into fact by
consolidating it. verified describes evidence authority, not permission to disclose:
publishing to a channel is a separately-scoped act.
Minting
titen bootstrap creates the organization and the first key: principal owner, kind
human, scopes ["*"], ceiling policy_approved. That is the only key that should ever
look like that.
titen key create --db titen.db --org-id org_724bbe3aab2a4286b25dabec65bce85e \
--label deploy-agent --kind agent --trust asserted \
--scopes "observations:write,claims:write,context:compile,feedback:write"
--org-id is required. Without --scopes you get the CLI’s default set, the kernel loop:
projects:resolve, observations:write, claims:write, context:compile,
feedback:write, evidence:read. --trust defaults to asserted, --kind to agent.
Add --print-sql to emit the INSERT for a remote database (Cloudflare D1) instead of
writing locally; the raw key still goes to stderr, once.
Over HTTP, with keys:manage:
curl -X POST http://127.0.0.1:8787/v1/keys \
-H "authorization: Bearer $TITEN_API_KEY" -H 'content-type: application/json' \
-d '{"label":"deploy-agent","scopes":["observations:write","context:compile"],"max_trust":"asserted"}'
{ "data": { "key_id": "key_3087befa94bc454fb8769630aa2eb1cf",
"api_key": "titen_sk_…", "label": "deploy-agent",
"scopes": ["observations:write", "context:compile"],
"max_trust": "asserted", "principal_kind": "agent",
"warning": "Store this key now. Titen keeps only its hash and cannot show it again." } }
principal_id defaults to a generated agent_… identifier and principal_kind to agent.
A key can never mint more authority than it holds:
An unrecognized scope is 400 VALIDATION_ERROR — Unknown scope "x".
A scope the caller lacks is 403 FORBIDDEN — This credential may not grant scope "keys:manage".
A higher ceiling than the caller's is 403 — A new credential may not exceed the creating credential's trust ceiling.
The raw key appears exactly once, in this response. No route and no support path can reissue it.
Revoking
curl -X DELETE http://127.0.0.1:8787/v1/keys/key_3087befa94bc454fb8769630aa2eb1cf \
-H "authorization: Bearer $TITEN_API_KEY"
# → {"data":{"key_id":"key_3087…","revoked_at":"2026-07-30T10:49:20.759Z","revoked":true}}
Idempotent: a second call returns the first revoked_at rather than moving it. An unknown or
foreign key id is 404. The CLI equivalent is titen key revoke --id <key id>, and
titen key list prints id, status, org, principal, ceiling, label and scopes for every key in
the database.
There is no rotation route. Rotation is three deliberate steps: mint the replacement, move traffic to it, revoke the old key. Keep the overlap short.
Least privilege in practice
One key per agent identity, labelled. Shared keys make revocation a decision about which service to break.
Scope to the routes that agent actually calls. A retrieval-only agent needs context:compile and nothing that writes.
Give the lowest ceiling the work tolerates. policy_approved belongs to an operator, not to a loop that summarizes tool output.
Keep keys:manage on one operator credential. Every other key that holds it can list and revoke every key in the organization.
Treat mcp:call as write authority: the seven tools behind it cover observations, checkpoints, leases and handoffs.
Never put a key in a repository file, plugin manifest, tool description, URL query or command-line argument. Use the host's secret store or a mode-0600 file.