Authorization
PASETO tokens, the four-tier capability stack, and how CortexDB evaluates authorization.
CortexDB authorizes every request through a four-tier capability stack and identifies callers with PASETO v4 public tokens. Authorization is enforced at the storage layer, not in application code.
The four-tier stack
Decisions cascade from outer to inner. An allow at an inner tier can override an outer allow (be more specific), but an outer deny is final.
1. Deployment policy ← preset-defined floor (cannot be overridden by tenant/scope)
2. Tenant policy ← per-tenant defaults
3. Scope policy ← per-scope ACLs and members
4. Actor policy ← per-actor overridesDeployment presets
Set with CORTEX_DEPLOYMENT_PRESET. These four values are the complete set.
| Preset | Notes |
|---|---|
dev_local | Default. Experimentals enabled. Local development only. A keyed server rejects actor-only requests with 401. |
on_prem_enterprise | Most permissive production preset; cross-workspace GDPR erasure allowed by default. |
cloud_shared_saas | Shared-cloud preset; experimentals gated; cross-workspace GDPR denied by default. |
cloud_private | Single-tenant cloud preset; experimentals gated. |
Authenticating an agent
Every request in a secured deployment carries:
Authorization: Bearer <PASETO v4 public token>
X-Cortex-Actor: user:aliceSelf-hosted auth modes
With CORTEX_API_KEY set, the key value itself is a working Bearer credential (constant-time compared),
alongside PASETO/JWT bearers. To run unauthenticated for local dev, start with
CORTEX_INSECURE_NO_AUTH=1 — requests are served without a token (served as user:local, or as whatever X-Cortex-Actor names; it does not restrict the bind, so publish the port on loopback only, -p 127.0.0.1:3141:3141). With
neither on a network-exposed bind, the server auto-generates a key and requires it. There is no
/v1/auth/login.
On a network, send the key with every request
Every deployment preset requires the key: a request carrying only an X-Cortex-Actor header gets
401 MISSING_TOKEN.
Pick your auth path
| You want to… | Use | Get a token by |
|---|---|---|
| Try the API in 60 s (prototype, AI self-install) | Anonymous sign-up | POST /v1/auth/signup {} → token + actor + scope. Self-hosted, the in-binary minter is off by default on a keyed server (503 NOT_CONFIGURED) — enable with CORTEX_V1_MINTER_ENABLE=1; under CORTEX_INSECURE_NO_AUTH=1 signup is served. Off on CortexDB Cloud (CORTEX_DISABLE_ANON_SIGNUP=1): cloud accounts sign up at cortexdb.ai and create tokens on the dashboard. |
| Run a service account | Server-side mint | Token with auth.mint, then POST /v1/auth/tokens with subject/ttl/scopes. In-binary minter is dev-only. |
| Production with permanent identities | Your IdP issues PASETO | Register your IdP's signing key (IssuerRegistry); CortexDB verifies. |
| Dashboard / MCP server | cx_live_… API key from the dashboard | A separate credential format the BFF exchanges for PASETO server-side. |
Token claims
{
"iss": "https://idp.acme.com/realms/main",
"sub": "user:alice",
"aud": "cortexdb:tenant:acme",
"exp": 1763212929,
"jti": "j_01HX...",
"deployment": "cloud_shared_saas",
"caps": ["scope.read.local", "scope.write"],
"scopes": ["scope.read:org:acme/dept:eng/*", "scope.write:org:acme/dept:eng/user:alice"]
}X-Cortex-Actor must match sub. A token can only narrow what policy already allows — it cannot grant
a capability the deployment denies.
capsnarrows the token to the listed capabilities (empty inherits the deployment and tenant defaults).scopesconfines capabilities to scope subtrees. Each entry is<capability>:<scope-path>[/*]: an exact capability or a prefix (scope.readcoversscope.read.local,scope.read.holistic, …), then the subtree it is confined to. A capability no entry names stays unconfined. A bare path such as"org:acme/dept:eng"is refused at mint with422 INVALID_BODY("expected<capability>:<scope-path>[/*]").- Neither claim makes the caller a member of a scope. Reading or writing a registered scope also
needs a seat on its scope roster; a freshly minted agent that holds the
right capabilities but no seat is refused with
403 POLICY_DENIED.
Application tokens on your own server
An application usually gets its own token instead of the operator key: minted by the server, confined to the scopes it uses. Three settings decide whether those tokens work with scopes the operator registered:
- Turn the minter on.
CORTEX_V1_MINTER_ENABLE=1. With a key set and the minter off,POST /v1/auth/tokensanswers503 NOT_CONFIGURED. - Put the tokens in the operator's tenant. The operator key acts in tenant
default. A minted token acts in the tenant of the server's audience,CORTEX_V1_AUDIENCE, which iscortexdb:tenant:devunless set. A scope belongs to the tenant that registered it, so with the default audience an app token is refused (403) on every scope the operator key registered. Start the server withCORTEX_V1_AUDIENCE=cortexdb:tenant:defaultand both share one tenant. - Give the token a seat. Register the scope with the token's subject among its
members(writerto write,readerto read), or add it withPUT /v1/scopes/members.
docker run -d … -e CORTEX_API_KEY -e CORTEX_V1_MINTER_ENABLE=1 \
-e CORTEX_V1_AUDIENCE=cortexdb:tenant:default cortexdb/cortexdb:<release>
curl -fsS --fail-with-body -X POST "$CORTEX_URL/v1/scopes" -H "Authorization: Bearer $CORTEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"path":"org:acme","members":[{"actor":"agent:assistant","role":"writer"}]}'
APP_TOKEN=$(curl -fsS --fail-with-body -X POST "$CORTEX_URL/v1/auth/tokens" -H "Authorization: Bearer $CORTEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"subject":"agent:assistant","ttl_seconds":86400,"caps":["scope.write","scope.read"],"scopes":["scope.write:org:acme/*","scope.read:org:acme/*"]}' \
| jq -r .token)The app then sends Authorization: Bearer $APP_TOKEN with X-Cortex-Actor: agent:assistant.
Remember that recall's default view, holistic, needs scope.read.holistic (scope.read covers
it). Tokens the server mints stop working when it restarts, though the data stays: mint new ones
after a restart, or have your identity provider issue tokens.
Denials
A 403 cites the tier + capability + reason:
{
"error_code": "POLICY_DENIED",
"message": "forget.gdpr.cross_workspace denied",
"details": {
"capability": "forget.gdpr.cross_workspace",
"decided_by_tier": "deployment",
"reason": "preset cloud_shared_saas denies"
},
"retriable": false
}Introspect the allow/deny surface with GET /v1/policy/effective?actor=…&scope=…. That
endpoint returns allowed/denied as flat capability-name arrays (plus deployment_preset,
rate_limits) — the per-entry tier/reason shown in the denial body is not returned there. See the
Policy API reference.
Common capabilities
| Capability | Gates |
|---|---|
scope.read.local / .holistic / .descend | read at scope / traverse ancestors / traverse descendants |
scope.write / .elevated / .on_behalf_of / .about_other | write / write above natural scope / set observed_actor / set subject |
forget.cascade.derived_only / .redact_events | selective forget / blank event payloads |
forget.gdpr / .cross_workspace | true event deletion via /v1/erasures / propagate into co-owned workspaces |
llm.invoke | call /v1/answer |
diagnostics.read | request diagnostics ≠ "none" |
understanding.synthesize | trigger /v1/understanding/synthesize |
FAQ
Can an inner policy override an outer one? An inner allow can be more specific; an outer deny is always final.
Does CortexDB use flat tenant identifiers? No — hierarchical scopes, so policy inherits down the hierarchy.