Skip to content
Version: Current

Authentication

2026-11-05. repository reference/snapshot reviewed 2026-11-05; verify current behavior before using as live guidance.

acumen authenticates two different callers with two different credentials and keeps both in one ledger without turning either into the other.

Loopback-only deployments can leave key enforcement off. Enforcement is armed at startup when a key is already stored (or ACUMEN_REQUIRE_KEYS=1), and it is armed immediately when a key is created at runtime: the first POST /v1/router/keys takes effect on the next request, not the next start.

A revoke is visible on the next request because every request looks the key up. Once armed, an empty key table stays armed; it fails closed. A restart with an empty table is the documented way back to an unarmed deployment.

With ACUMEN_REQUIRE_KEYS=1 and an empty key table the process is locked out of its own key API, because creating the first key is itself an admin call.

Plane Credential Issued by Presented on Stored as
machine virtual key vk_… POST /v1/router/keys (admin key) or POST /app/grants/new (consumer session) Authorization: Bearer vk_… on /v1/*, and on operator paths when the key holds admin SHA-256 hash in virtual_keys; plaintext returned once
human browser session cookie acumen_session accepting an invite (POST /i/{token}) or signing in (POST /signin) the cookie on /app/* and /account SHA-256 of the token in sessions; a second per-session secret holds the CSRF value

A virtual key is not a login: it cannot open /app or /account, and it cannot mint another key unless it holds admin. A session cookie is not a model credential: it cannot call /v1/*.

The human plane exists because a person signing in has no key yet. The machine plane is unchanged for SDKs and CI. Sign-in is invite-only and uses a recovery code.

For a complete migration guide covering base URL changes, virtual key handling, model aliases, streaming, error handling, and failover, see Migrating a client to acumen.

grants is the one list of everything that can act for an account:

  • kind=api: a virtual key, with the same id as its virtual_keys row, so the credential and its lifecycle record cannot drift apart.
  • kind=session: a browser sign-in, id grt_ plus the sessions.id.
  • kind=delegation: reserved for scoped delegation; no handler writes it yet.

Every grant carries label, scopes, models, the caps, origin, created_by, state (active, disabled, revoked, or the computed expired), created_at, expires_at and revoked_at. grant_events is the append-only history (created, revoked, signed in, account frozen), rendered on the grant page.

The invariant is one api grant per virtual key, with the same id. It holds for keys that predate the ledger and for keys created on the consumer surface. It does not yet hold for a key created through POST /v1/router/keys: that handler writes virtual_keys only. That is an open gap, not a second source of truth. Enforcement always reads virtual_keys.

Revoking either path kills the key. The consumer’s POST /app/grants/{id}/revoke deletes the virtual_keys row behind an api grant; the operator’s DELETE /v1/router/keys?id=… deletes the same row. Because KeyAuth looks the key up on every request, the next call is 401 invalid virtual key with no restart. Session revocation deletes the sessions row, so the cookie stops resolving immediately.

A key carries scopes (chat, embeddings, admin), an optional model allowlist (empty means every model), daily and monthly USD caps, and RPM/TPM limits.

The model allowlist is checked before the exact-cache lookup, so a cached premium answer is never replayed to a key that may not call that model.

Rate and spend windows live in memory: a restart resets the current minute, day and month windows.

Scope Reaches
chat the model doors and /v1/models
embeddings /v1/embeddings
admin every operator path, plus key CRUD

There are three surfaces across two credential planes, and /healthz is the only path that never checks a credential.

Surface Paths Who may call it
public liveness /healthz anyone; returns ok and nothing about seats
operator /health (deep, per-seat balances), /readyz, /console*, /metrics, /debug/pprof/*, /v1/router/*, /v1/hook*, /v1/route*, /v1/keys* an admin-scoped key, or a loopback deployment with enforcement unarmed
model /v1/chat/completions, /v1/responses, /v1/messages, /v1/search, /v1/models a chat key, or none when unarmed
embeddings /v1/embeddings an embeddings key
consumer /, /i/{token}, /signin, /signout, /app*, /account* a browser session cookie; /, /signin and /i/{token} need none. Mounted ahead of KeyAuth, so no vk_ key is involved

The deep /health body used to fall through to the chat scope, so any chat key could read every seat’s plan, balance and circuit state. It is now an operator path: a chat key gets 403 X-Acumen-Denied-By: scope, an admin key gets 200.

Public binds fail closed. A non-loopback listener is refused at startup unless -allow-remote is passed and ACUMEN_BEARER_TOKEN is set (validateBind). On such a listener the process token is required on every path except /healthz; /readyz and deep /health are not exempt. A listener that fails these checks does not start.

Residual trust on a proxied loopback deployment. A reverse proxy on the same host connects from 127.0.0.1, so acumen cannot tell a tailnet or public client from a local one. A loopback bind with enforcement unarmed therefore trusts whatever can reach the proxy. On the edge host, Caddy denies the operator paths and tools/edge-check.sh asserts it from the host. Any deployment exposed beyond the host should set ACUMEN_REQUIRE_KEYS=1, or create a key, so operator paths need an admin key regardless of peer address.

POST /v1/router/keys {"name":"ci","scopes":["chat"],"models":["cheap"],
"daily_usd":5,"monthly_usd":50,"rpm":60,"tpm":200000}
GET /v1/router/keys list (ids, limits, spend; never a key or hash)
PATCH /v1/router/keys {"id":"key-…","daily_usd":10,"disabled":false}
DELETE /v1/router/keys?id=key-…

POST mints the key and returns the plaintext once. PATCH updates only the fields present in the body (name, scopes, models, daily_usd, monthly_usd, rpm, tpm, disabled) and answers 200 with the same view as GET; unknown fields are rejected, and the hash and created_at are not writable. DELETE is the revoke.

All four require a key with the admin scope, as does every operator path above.

Condition Status X-Acumen-Denied-By
missing or unknown key 401 key
disabled key that lacks the path’s scope 403 scope (the scope check runs before the disabled flag)
disabled key holding the path’s scope 401 key
scope not held (e.g. a chat key on /health) 403 scope
model not on the allowlist (cache hit or dispatch) 403 model
RPM window exceeded 429 with Retry-After rate
TPM window exceeded 429, no Retry-After; retry after the minute rolls rate
daily or monthly USD cap crossed 402 budget
  • Use the gateway runbook for operator checks and key/seat operations.
  • For request behavior and endpoints, see the acumen API guide and API reference.
  • Verify current auth behavior on the target deployment before relying on this snapshot (reviewed 2026-11-05).