API keys
Personal keys and their role inheritance, the three scope types, the LiteLLM and MCP key brokers, and the fail-closed guards that stop a broker handing out privilege.
Atrium issues atr_live_… bearer tokens. Only the hash is stored, so a secret is
returned exactly once and can never be re-read.
There are two fundamentally different kinds, and the difference is what roles the key authenticates with.
| Kind | Roles it carries | Who mints it | Typical holder |
|---|---|---|---|
| Personal (unscoped) | The minter's real roles | Any signed-in user, for themselves | A human, or their MCP client |
| Scoped | None. An empty role set | Admin | A product calling Atrium |
Personal keys
Any signed-in user can mint one for themselves at /admin/api-keys, backed by
POST /api/admin/api-keys. The /admin prefix in that path is a naming quirk,
not an authorization statement: the route authenticates any session or key, and
no role check applies to minting your own.
Admin is required for two narrower things: minting a scoped key, and seeing other users' scoped keys in the listing.
A personal key inherits roles from the user's rolesSnapshot, resolved on every
call rather than frozen at mint time. Losing atrium:admin in Zitadel
immediately downgrades what the key can see and do.
Scoped keys are refused by both the list and mint routes with a 403: a scoped key cannot mint keys.
One legacy behaviour, slated for removal
An unscoped key whose user has an empty rolesSnapshot currently falls
back to atrium:super_admin. It exists as a lock-out guard so keys minted before
the snapshot column existed keep working.
Do not build on this. It is a privilege escalation waiting to be triggered by any
user who has not signed in since the column was added, it is scheduled for
deletion once rolesSnapshot is backfilled for every active user, and the
brokers below already refuse to mint anything that would land in it. Treat "an
unscoped key with no snapshot" as an error condition, not as a way to get an
admin token.
Scoped keys
A scoped key holds no roles at all. That empty set is the floor, not a tier: the key does only what a scope explicitly grants, and nothing else. It cannot list Atrium keys, read the directory, or touch anything outside its scope.
Three scope types ship today:
| Scope | Bounded by | Actions | Lets the holder |
|---|---|---|---|
group_membership | groupIds | add, remove | Change membership of the named groups |
llm_key | appIds | mint, revoke | Broker LiteLLM virtual keys under those apps' teams |
mcp_key | appIds | mint, revoke | Broker per-user Atrium MCP keys for those apps |
The id allowlist is the blast-radius boundary. A key scoped to one app can never act under another app's team.
llm_key and mcp_key are deliberately separate scopes rather than two
actions on one scope, because they mint credentials with very different blast
radii: a LiteLLM virtual key can spend money, while an Atrium key can read the
directory as the user. A product entitled to one is not automatically entitled to
the other.
Scoped keys cannot use MCP
/api/mcp rejects scoped keys outright. MCP tiers its tool catalog by the
caller's effective role, and a scoped key has no roles to tier by, so the endpoint
refuses rather than serving an empty or misleading catalog. Use a personal key
for MCP. See MCP.
The LiteLLM key broker
POST and DELETE /api/apps/<appId>/llm-keys let a registered product ask
Atrium to mint a per-(user, project) LiteLLM virtual key under that app's
team. Spend rolls into the team budget.
The security property that makes this worth a broker: the LiteLLM master key
never leaves Atrium. Only Atrium's management client talks to the proxy admin
surface. Products authenticate to Atrium with a narrow llm_key-scoped key, so
leaking that key can mint keys for one app's team and can never escalate to
proxy-admin.
Atrium never persists the minted secret. It is returned once and stored on the product's side. The ledger keeps only the proxy token id and alias (enough to revoke) plus the (app, subject, project) identity. A uniqueness constraint on that triple makes minting ensure-fresh: a re-mint deletes the prior proxy key and supersedes the ledger row, so a product can always re-request a working secret without accumulating duplicates.
Attribution and budget work without the Enterprise-gated fields, because the self-hosted proxy is the OSS build:
- Per-project attribution rides on the key alias (which embeds app, subject
and project), on the team itself, and on
user_idset to the subject's email. - Per-user budget is governed at the person level. Each mint ensures an
email-keyed internal-user record exists; the proxy stamps it with the default
spend envelope, and most-restrictive-wins makes that envelope cap all of the
person's keys across every project. Keys are therefore minted uncapped within
the person's boundaries. An explicit
maxBudgetcan add a tighter per-key sub-cap, and can only narrow the envelope, never widen it.
Mint refuses, rather than degrading, when the app is unknown, LiteLLM is disabled for it, the team is not yet provisioned, the proxy is not configured, or the app's model scope cannot be resolved. That last one is a fail-closed EU-scope guard: if the app has a model-scope pattern but discovery errored or matched zero models, Atrium refuses rather than issuing an unrestricted key.
The MCP key broker
POST and DELETE /api/apps/<appId>/mcp-keys mint the per-user Atrium key that
authenticates one user's Atrium MCP connector, so /api/mcp runs as that user
and tiers the tool catalog by their real role.
The key it mints is unscoped, on purpose: a scoped key would authenticate with no roles, which is the floor rather than the user's RBAC. That is precisely why this broker needs guards, and it has two, both fail-closed.
| Guard | Status | Refuses when |
|---|---|---|
roles_unresolved | 409 | The subject's role snapshot is empty |
subject_too_privileged | 403 | The subject is a super-admin, or out-ranks the caller |
roles_unresolved exists because minting into an empty snapshot would land
in the legacy super-admin fallback described above. Automatically brokering that
would hand super-admin over Atrium to every user who has not signed in since the
column was added. So the broker refuses, and the user gets no Atrium MCP entry
until they sign in to Atrium once.
subject_too_privileged is the privilege ceiling, and it is two rules:
- No super-admin subjects, ever. No broker path may hand out a long-lived
credential that authenticates as super-admin. A super-admin who wants an MCP
key mints their own at
/admin/api-keys, where the act is theirs and visible on their own key list. - A subject may not out-rank the caller. When the caller has a role identity
of its own (an admin session, or an unscoped admin key), the subject's roles
must be a subset of the caller's. A scoped
mcp_keyproduct token has no roles of its own, so containment is meaningless for it and rule 1 alone bounds it. That is the point of the scope: broker for ordinary humans, never for the platform's owners.
Minting is ensure-fresh like the LiteLLM broker: it revokes any prior live MCP key for the same (app, user) and returns a brand-new secret. Callers must cache the returned key and re-broker only on a cache miss. Re-brokering per request would revoke the key the user is currently running on. Default lifetime is 90 days.
An unknown subject (no Atrium user for that email) is a 404: they must sign in
to Atrium once first.
Audit
Every authenticated API-key call writes an AtriumApiKeyUse row and updates the
key's lastUsedAt.
Two honest caveats about the current implementation. The audit write is
fire-and-forget: it is not awaited, and a failure is logged rather than
failing the request, so it should be read as strong-but-not-guaranteed evidence.
And the row's status is written as 0, meaning "started", and no route
currently updates it afterwards, so it does not tell you whether the call
succeeded.
Brokered keys are additionally identifiable as a set by their generated name, so every MCP key issued for one app can be revoked together.
Related
- MCP for what a personal key exposes over JSON-RPC
- AI agent for the agent API, which takes the same bearer tokens
- Members and RBAC for how roles resolve in the first place