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 come from the credential, always. 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 |
Reads and writes split for a reason. 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, not a
statement about the credential itself. 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" } }
Two consequences. 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. And 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 —
projects:resolve, observations:write, claims:write, context:compile,
feedback:write, evidence:read — which is the kernel loop and nothing else. --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. There is no recovery route and no support path that 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 — with the overlap as short as you can make it.
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.