Documentation menu

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.

KindRoles it carriesWho mints itTypical holder
Personal (unscoped)The minter's real rolesAny signed-in user, for themselvesA human, or their MCP client
ScopedNone. An empty role setAdminA 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:

ScopeBounded byActionsLets the holder
group_membershipgroupIdsadd, removeChange membership of the named groups
llm_keyappIdsmint, revokeBroker LiteLLM virtual keys under those apps' teams
mcp_keyappIdsmint, revokeBroker 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_id set 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 maxBudget can 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.

GuardStatusRefuses when
roles_unresolved409The subject's role snapshot is empty
subject_too_privileged403The 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:

  1. 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.
  2. 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_key product 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.

  • 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