# 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.

Section: Integrate · Source: https://titen.dev/docs/keys-scopes
Derived from: https://github.com/RamaAditya49/titen/blob/main/docs/security/threat-model.md

---
## 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:

<div class="kv">
<strong>WHAT THE CREDENTIAL CARRIES</strong>

<p><code>org_id</code> the tenant. Never read from a request body.</p>
<p><code>principal_id</code> the actor recorded as <code>actor_id</code> on the rows it writes.</p>
<p><code>principal_kind</code> <code>human</code>, <code>agent</code> or <code>service</code>.</p>
<p><code>label</code> a human name, so revoking the right key is not guesswork.</p>
<p><code>scopes</code> a space-separated list, or <code>*</code> for all of them.</p>
<p><code>max_trust</code> the ceiling on evidence this key may assert.</p>

</div>

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.

<div class="table-wrap">

| 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` |

</div>

`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:

```json
{ "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.

```sh
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`:

```sh
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"}'
```

```json
{ "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:

<div class="rules">

<p>An unrecognized scope is <code>400 VALIDATION_ERROR</code> — <code>Unknown scope "x".</code></p>
<p>A scope the caller lacks is <code>403 FORBIDDEN</code> — <code>This credential may not grant scope "keys:manage".</code></p>
<p>A higher ceiling than the caller's is <code>403</code> — <code>A new credential may not exceed the creating credential's trust ceiling.</code></p>
<p>The raw key appears exactly once, in this response. No route and no support path can reissue it.</p>

</div>

## Revoking

```sh
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

<div class="rules">

<p>One key per agent identity, labelled. Shared keys make revocation a decision about which service to break.</p>
<p>Scope to the routes that agent actually calls. A retrieval-only agent needs <code>context:compile</code> and nothing that writes.</p>
<p>Give the lowest ceiling the work tolerates. <code>policy_approved</code> belongs to an operator, not to a loop that summarizes tool output.</p>
<p>Keep <code>keys:manage</code> on one operator credential. Every other key that holds it can list and revoke every key in the organization.</p>
<p>Treat <code>mcp:call</code> as write authority: the seven tools behind it cover observations, checkpoints, leases and handoffs.</p>
<p>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-<code>0600</code> file.</p>

</div>

<div class="cards">

<a href="/docs/identity"><strong>Identity &amp; visibility</strong><span>How scopes combine with visibility to decide what a principal can read.</span></a>
<a href="/docs/mcp"><strong>MCP server</strong><span>What <code>mcp:call</code> opens, and where its checks differ from REST.</span></a>
<a href="/docs/api"><strong>HTTP API</strong><span>The full route table with the scope each one demands.</span></a>

</div>