Identity and Tokens
The principal chain, what a minted token may carry and how long it lives, tokens from your own identity provider, the tenant directory, and SCIM provisioning.
Shared memory decides every request by who is asking, so each person and each agent should carry a token of its own rather than one shared key. 0.14.0 gives tokens a principal chain, puts limits on what a minted token may hold, accepts tokens from your identity provider, and adds a directory of names and sign-in accounts, which an identity provider can keep up to date over SCIM.
The principal chain
A token names its subject in sub (user:alice, agent:triage, service:sync). It may also say
what is acting for that subject:
| Claim | Says |
|---|---|
act | The agent acting for the subject: {"sub": "agent:assistant"} |
run | The agent's run, run_start when it began |
app | The application the request comes through: service:<id> |
client | person or agent |
taint | Tags carried from what the chain read, such as ["external"] |
auth_time | When the person last signed in |
minter | Who minted the token (operator, or a principal) |
A token with act or run, or whose client resolves to agent, is an agent chain token: it holds
only what its scopes entries name, and lives at most 24 hours. A token with act writes only within a
run (403 RUN_REQUIRED without one), and its writes are stored with the agent as observed_actor.
GET /v1/auth/whoami shows the chain:
{"caller": "user:alice", "tenant_id": "acme", "deployment_preset": "on_prem_enterprise",
"token": {"jti": "jti_01a186c0cbb876d1bb3e447ce6391a8f", "iss": "https://cortexdb.local/issuer", "exp": 1793265744},
"effective_capabilities": [],
"chain": {"act": "agent:assistant", "client": "agent", "run": "run-42", "run_start": 1793262144,
"minter": "operator", "issuer": "minter"}}(effective_capabilities lists only families a <capability>:* entry names; this token's entries are
confined to org:acme.)
An agent's write also carries what its run read, and is redirected to the person's private space when
it cannot show that safely: see Applications and Sources.
An AI employee acts under its own token (sub the employee, no act); a mint whose act names one is
refused.
Minting tokens
POST /v1/auth/tokens mints a token with this server's minter (CORTEX_V1_MINTER_ENABLE=1 on a keyed
server). Since 0.14.0 four rules hold, each on by default:
| Rule | What it refuses |
|---|---|
| Lifetime ceiling | A token living longer than 7 days for a user: or agent: subject, 30 days for a service: or system: subject, or 24 hours when it carries a chain field. At the mint: 422 INVALID_BODY, ttl_too_long; at every use of an older token past its ceiling: 401 INVALID_TOKEN_CLAIM, lifetime_over_ceiling. The default lifetime is one hour |
| Total confinement | On a token that carries scopes, any capability no entry names: 403 POLICY_DENIED, details.link: "confinement". Name every family the token uses; <capability>:* names one at every scope, and checks made without a scope (audit.read, blob.upload, lifecycle.subscribe, …) need it |
| The mint's reach | A mint that names no tenant root (scope, tenant or scopes), unless the operator key mints a service: or system: token; a token wider than its minter; a mint by a token another principal minted |
| The minter link | A token another principal minted, used where that minter could not act |
The operator key mints a person's token for one tenant like this:
curl -sS -X POST "$CORTEX_URL/v1/auth/tokens" \
-H "Authorization: Bearer $CORTEX_API_KEY" -H "Content-Type: application/json" \
-d '{"subject": "user:alice", "scope": "org:acme", "ttl_seconds": 3600}'
# {"token":"v4.public.…","jti":"jti_…","expires_at":"2026-10-29T09:04:53+00:00"}And an agent working for Alice in one run, confined to her tenant:
curl -sS -X POST "$CORTEX_URL/v1/auth/tokens" \
-H "Authorization: Bearer $CORTEX_API_KEY" -H "Content-Type: application/json" \
-d '{"subject": "user:alice", "scope": "org:acme", "ttl_seconds": 3600,
"act": "agent:assistant", "run": "run-42",
"caps": ["scope.read.local", "scope.read.holistic", "scope.write"],
"scopes": ["scope.read:org:acme/*", "scope.write:org:acme/*"]}'
# {"token":"v4.public.….eyJjZGIiOiJjaGFpbjEifQ","jti":"jti_…","expires_at":"…",
# "chain":{"act":"agent:assistant","run":"run-42","run_start":1793261247,"minter":"operator"}}What the rules answer when a mint goes past them:
{"error_code": "INVALID_BODY",
"details": {"reason": "ttl_too_long", "field": "ttl_seconds", "lifetime_s": 2592000, "ceiling_s": 604800},
"retriable": false}{"error_code": "POLICY_DENIED",
"message": "a mint that names no tenant root is deployment-wide, the trusted operator's alone and only for a service: or system: subject: name the root with scope, tenant or scopes",
"details": {"reason": "deployment_mint_requires_operator"}, "retriable": false}Each rule has a switch (CORTEX_MINT_LIFETIME_ENFORCE, CORTEX_TOKEN_CONFINEMENT_ENFORCE,
CORTEX_MINT_REACH_ENFORCE, CORTEX_MINTER_LINK_ENFORCE). off serves what the rule would refuse and
counts it in GET /v1/admin/access/report instead: a way to find the clients to fix after an upgrade,
not a setting to keep.
External issuers
Production deployments usually let their identity provider (Okta, Microsoft Entra ID, Auth0, Keycloak)
issue the tokens. CORTEX_AUTH_ISSUERS_FILE names a TOML file of trusted issuers: JWT (RS256 or
ES256, with pinned keys or a JWKS URL) and PASETO v4 (pinned keys). Each entry says how a token's
subject maps to a principal, which tenants it may name, which chain claims its tokens may carry and how
long they may live.
version = 1
[[issuer]]
id = "acme-idp"
iss = "https://login.acme.example"
format = "jwt"
algorithms = ["RS256"]
keys = ["keys/idp.pem"] # or jwks_url = "https://login.acme.example/.well-known/jwks.json"
max_lifetime_s = 3600
[issuer.subject]
mode = "directory" # the person whose directory entry holds this sign-in account
kinds = ["user"]
[issuer.tenants]
allowed = ["acme"]
aliases = [{ aud = "https://memory.acme.example/mcp", tenant = "acme" }]subject.mode | Maps the token's subject to |
|---|---|
directory | The principal whose directory entry lists the account ({system, account}; the system is the entry's iss unless set) |
actor | The value itself, which is already a principal id (user:alice) |
prefix | <kind>:<namespace>.<value>, such as user:acme.00u1abcd |
hash | <kind>:<namespace>.<32 hex digits>, an HMAC of the value |
Use directory mode for people
A prefix or hash principal id holds a ., which a scope path cannot. Such a person cannot have a
scope named after them (their own region org:acme/user:<id>), and the MCP server's default allowlist,
which is built from the principal id, names no scope for them. Map people with directory (or
actor) mode.
A token of a directory, prefix or hash entry may be sent without X-Cortex-Actor, since its
client cannot know the principal it maps to; GET /v1/auth/whoami answers it:
curl -sS "$CORTEX_URL/v1/auth/whoami" -H "Authorization: Bearer $IDP_ACCESS_TOKEN"
# {"caller":"user:dana","tenant_id":"acme","token":{"jti":"acme-idp:7dca…","iss":"https://login.acme.example",…},…}tenants.aliases maps an audience that is not cortexdb:tenant:<id>, such as the MCP server's resource
URL, to a tenant: that is how an identity provider's access token for the MCP server
works. A token past max_lifetime_s is 401 INVALID_TOKEN_CLAIM (lifetime_over_maximum), one whose
audience the entry does not admit 401 WRONG_TENANT. POST /v1/admin/auth/reload (operator key)
re-reads the file without a restart; a file that breaks a rule is refused and the running entries kept.
An issuer's token never mints.
The directory
The directory holds each tenant's principals (names, emails, titles, sign-in accounts) and the names of its scopes, sets and applications. Names live here rather than in scope records, because records are kept for ever in the log and a directory entry can be erased.
curl -sS -X PUT "$CORTEX_URL/v1/directory/user:alice?scope=org:acme" \
-H "Authorization: Bearer $CORTEX_API_KEY" -H "Content-Type: application/json" \
-d '{"display_name": "Alice Kim", "emails": ["alice@acme.example"], "title": "Engineering manager"}'
# 200, ETag: "1"
# {"id":"user:alice","display_name":"Alice Kim","title":"Engineering manager","status":"active",
# "emails":["alice@acme.example"],"accounts":[],"version":1}
curl -sS "$CORTEX_URL/v1/directory?scope=org:acme&q=ali" \
-H "Authorization: Bearer $ALICE_TOKEN" -H "X-Cortex-Actor: user:alice"- Any principal of the tenant reads names and titles; emails, accounts and attributes only the tenant root's Owners, the operator and the principal itself. A private scope's name only its readers.
- A scope or set is named by its path, with
/sent as%2F:PUT /v1/directory/org:acme%2Fteam:eng?scope=org:acme{"display_name": "Engineering"}. "status": "deactivated"stops every credential of the person at its next request, whichever issuer signed it (401 SUBJECT_UNMAPPED,deactivated), until the entry is active again. Their memberships are kept.If-Matchwith the entry's version makes a write conditional (412 DIRECTORY_VERSION_CHANGED).- Register the tenant root first: until a record registers it, only the operator key administers the tenant's directory.
SCIM
An identity provider provisions a tenant's people and groups at /scim/v2/Users and /scim/v2/Groups
(SCIM 2.0, with ServiceProviderConfig, ResourceTypes and Schemas). Users are directory entries;
Groups are sets, whose membership changes are applied as change sets. active: false deactivates the
person as above. The caller is the tenant's SCIM client: a token whose subject is
service:scim-<client> with the tenant's audience, which must be an Owner of the tenant root. SCIM needs
the default one-segment tenant roots (CORTEX_TENANT_DEPTH 1): deeper roots answer
501 SCIM_UNSUPPORTED.
See also
Change Sets
Stage access changes, preview who gains and loses access, and activate them at once or on a date, with the tenant's own assertions as a guard.
Operator Modes and Break-Glass
What the static operator key may read under allow, audit and deny, the presets' defaults, the break-glass header, a tenant's own mode, and the access report that shows when it is safe to tighten.