Auth architecture
Zimmer has four separate authentication systems that share almost nothing. Understanding which is which is most of the battle.
1. Human → Zimmer: there is no authentication (except the operator realm)
Section titled “1. Human → Zimmer: there is no authentication (except the operator realm)”This is not a simplification. ApplicationController has no before_action for auth, no session
auth, no Devise, no OmniAuth. There are no login routes. There is no User model in the auth path.
Everything is open to anyone who can reach the host:
- the session dashboard and every transcript,
/settings,/inference(including the OAuth login flow),- the GoodJob dashboard at
/jobs.
The exception: the operator realm, in front of two surfaces
Section titled “The exception: the operator realm, in front of two surfaces”Two surfaces are where “anyone who reaches the host” is too generous, and they share one HTTP Basic
realm — OperatorHttpBasicAuth (app/controllers/concerns/operator_http_basic_auth.rb):
before_action :authenticate_operator
def authenticate_operator expected_password = ENV[PASSWORD_ENV].to_s return refuse_operator_unconfigured if expected_password.blank? # ...constant-time compare of username and password, then `refuse_operator` on failureend/supervisor, because the Administrate admin panel renders claude_accounts (whose
oauth_config JSONB holds plaintext access and refresh tokens), mcp_oauth_credentials,
x_oauth_credentials, and runtime_login_attempts as editable resources. It is also where the X
consent flow runs, so minting an X credential takes the operator credential on every leg.
The mutating POST /health/* actions — cleanup_processes, retry_sessions, archive_old,
enter_queue_recovery_mode and run_post_deploy_tasks — because they terminate processes, rewrite
session rows in bulk, and halt the fleet’s demand-side job queues. Every GET on /health stays
anonymous: a read-only dashboard behind the perimeter is the design, and /up and /up/deep are
what kamal-proxy gates the deploy cutover on. So does POST /health/exit_queue_recovery_mode — the
way out of a halt must always work, including on a deployment that never set the variable.
Sharing one realm string across both is deliberate on the human side too: browsers cache Basic
credentials per origin and realm, so an operator who has opened /supervisor is already carrying
what the /health buttons ask for.
One shared credential, no user model — this is not “who are you”, it is “are you inside the
perimeter at all”. Set SUPERVISOR_PASSWORD; SUPERVISOR_USERNAME is optional and defaults to
supervisor. Both halves are compared with ActiveSupport::SecurityUtils.secure_compare, the same
constant-time primitive Api::BaseController#authenticate_api_key uses.
The refusal and the challenge are separate
Section titled “The refusal and the challenge are separate”A 401 is the gate saying no. The WWW-Authenticate: Basic header on it is a second, separable
instruction — “go ask the human” — and browsers obey it for any same-origin credentialed fetch,
not just for a navigation someone started (WHATWG Fetch, HTTP-network-or-cache
fetch).
Turbo Drive prefetches same-origin links 100ms after the cursor enters them, which made that distinction load-bearing: hovering the dashboard’s Supervisor button fired a background GET at the realm, and the browser opened its native sign-in dialog on top of a page nobody was leaving. It looked random because it tracked the mouse rather than any click.
So Supervisor::ApplicationController#refuse_operator withholds the challenge — and only the challenge —
from a request the browser made speculatively (SpeculativeRequest#prefetch_request?, which reads
X-Sec-Purpose, Sec-Purpose and Purpose). The request is still refused with a 401; a real
navigation still gets the challenge and still signs in. Belt and braces, every link to the realm
also carries data-turbo-prefetch="false", so the request is not made at all —
test/contracts/basic_auth_prefetch_test.rb sweeps app/views/** and fails if a new one forgets.
It fails closed. With SUPERVISOR_PASSWORD unset — or blank, which is what a trailing space in
an env file gets you — every request to every dashboard gets a 401, and the refusal is logged.
An unconfigured deployment gets no admin panel rather than an anonymous one — so on a fresh deploy
you must set the variable before /supervisor will open for you either.
The same is true of /health’s maintenance buttons, and there the closed state has a route around
it rather than being a dead end: the identical actions are on POST /api/v1/health/* behind
API_KEYS, and exit_queue_recovery_mode is ungated on purpose, so an instance that never set the
variable can still be got out of a halt. The 401 body names SUPERVISOR_PASSWORD, so the refusal is
diagnosable from the response and not only from the log.
There is no per-user authorization in SessionsController, and that is the design
Section titled “There is no per-user authorization in SessionsController, and that is the design”Sessions have no owner column and there is no principal to compare one against, so there is nothing
for a policy object to decide. SessionsController says so explicitly at the top of the class, and
each action that used to carry a # TODO: Add authorization check comment now points at that note.
Those comments read as unfinished work; they described the product’s shape. See
the philosophy for why a single circle of trust has no ACLs to build.
2. Client → REST API: X-API-Key
Section titled “2. Client → REST API: X-API-Key”The only authenticated surface. Api::BaseController#authenticate_api_key compares the X-API-Key
header against ENV["API_KEYS"] (comma-separated) using a constant-time comparison.
What it isn’t:
- No scoping. Keys are opaque strings with no identity, no permissions, no ownership. Any valid key can read, mutate, and delete every session, trigger, and category.
- No rotation without a restart — the valid-key list is memoized per request instance from ENV.
- No audit trail of which key did what.
Two endpoints take a different credential instead:
POST /api/v1/elicitations/session/:tokenandGET /api/v1/elicitations/session/:token/:request_id— the MCP fallback-elicitation protocol. The MCP child process has no key, so it authenticates with a per-session token in the URL path, which Zimmer puts in its environment at spawn. A token reaches only its own session’s elicitations, and it is not derived from any API key. See who may raise a prompt.
3. Zimmer → the agent vendor
Section titled “3. Zimmer → the agent vendor”A pool of accounts (ClaudeAccount — misleadingly named; it serves the two runtimes that have a
pool, discriminated by a runtime column) with automatic OAuth refresh and automatic rotation when
one hits its quota.
Pi is outside all of it: it resolves a provider API key (OPENROUTER_API_KEY) per request from the
session environment, so there is no account row, nothing to refresh and nothing to rotate.
→ Agent harness credentials · Runtimes
4. The agent → MCP servers
Section titled “4. The agent → MCP servers”A completely separate system: McpOauthCredential + McpOauthPendingFlow, doing full RFC 8414
discovery, RFC 7591 dynamic client registration, and PKCE — then writing the resulting tokens into
the CLI’s own credential file so the agent’s MCP client picks them up.
X (Twitter) is the exception: its token is an XOauthCredential row, vended as an env var, and
minted by a consent flow that runs from /supervisor behind the operator realm.
→ MCP server OAuth, and X (Twitter) is minted from
/supervisor
Prefer remote MCP servers to long-lived API tokens
Section titled “Prefer remote MCP servers to long-lived API tokens”When you give an agent a new capability, you usually have two ways to do it:
- a stdio MCP server or a CLI — a local process that reads a long-lived API token out of the
environment (
env: { "LINEAR_API_KEY": "${LINEAR_API_KEY}" }), or a CLI youop-inject a token into and then shell out to; - a remote MCP server — an
http/streamable-http/sseendpoint that Zimmer authorizes once over OAuth, with dynamic client registration, PKCE, and a refresh token it rotates for you.
Reach for the remote server. The difference shows up on the bad day, not the good one. Agents read and write an enormous amount of text — logs, transcripts, diffs, error messages they paste back to themselves — and a credential that lives in the environment will eventually land in one of them. If that credential is a long-lived API token, your options are to accept a permanent exposure or to spend the next five hours hunting down and rotating every copy of it. If it is a short-lived OAuth token that Zimmer already rotates on a schedule, it expires on its own, and revoking it is one click plus a browser re-auth.
The same asymmetry is why the harness itself signs in rather than taking an API key: see agent harness credentials.
Strad is the remote-MCP platform built to pair with Zimmer — the place to put the servers you’d otherwise be running as token-hungry local processes. Its docs are going up now.
Nothing is encrypted at rest
Section titled “Nothing is encrypted at rest”In transit, at least, one field is guarded
Section titled “In transit, at least, one field is guarded”x_oauth_credentials.token_endpoint is editable in that panel, and it decides both where the
token request goes and whether TLS is used — XOauthCredential.post_token_request sets
use_ssl from the URL’s scheme, and the X client secret rides along as HTTP Basic. An http://
value there puts a long-lived confidential-client secret on the wire in the clear, and whether
anything surfaces afterwards depends on what is listening: a plain redirect lands in
last_refresh_error, but anything that speaks the token protocol answers 200 and the leak
leaves no trace.
The model refuses it: token_endpoint must parse as an https:// URL with a host and no embedded
credentials. There is no loopback exception — X publishes no plaintext token endpoint, so https
is the only value the field was ever meant to hold, and an exceptionless rule is what makes the
panel reviewable. A migration repairs any row that predates the rule, applying that same predicate
rather than a LIKE approximation of it, because a legacy http:// row would otherwise POST first
and only fail validation on the way to saving the rotated refresh token — a leak and a dead
credential in one pass.
mcp_oauth_credentials.token_endpoint now carries the same rule, for a worse version of the same
defect: the MCP refresh grant sends the client_secret and the refresh token as form
parameters, so http:// publishes both — and the refresh still returns 200, so nothing surfaces.
The MCP endpoint is not only operator-editable, it is discovered: McpOauthService reads it out
of each server’s own authorization-server metadata. That is why the rule is exceptionless here too,
loopback included. A carve-out would be triggerable by the one party outside Zimmer’s control, and a
remote server naming this host’s loopback has no honest meaning — it would aim a POST carrying an
operator-supplied client secret at whatever answers on Zimmer’s own port. A developer pointing at a
local MCP server gets a clear refusal instead of a silent cleartext POST.
The rule is enforced at four depths, because the endpoint is read at four:
| Where | What it does |
|---|---|
McpOauthCredential | Refuses to save a non-https endpoint (blank stays legal — it means “re-authorize me”) |
McpOauthCredential#can_refresh? | False for a cleartext endpoint, so cron and the injector never call refresh! on one |
McpOauthPendingFlow | Refuses to store one, so the initial code exchange cannot leak it either |
McpOauthService#post_form | Raises InsecureEndpoint rather than opening a cleartext connection |
McpOauthService#post_json | The same rule on the DCR registration endpoint, whose response mints a client secret |
McpOauthController#initiate checks before it redirects to consent, so a server advertising a
cleartext token endpoint is refused with a flash naming it — no authorization code is ever minted
for an endpoint Zimmer will not talk to.
A migration clears any row that predates the rule (to NULL, since an MCP token endpoint has no
default to reset to — re-authorizing rediscovers it). Without that, validation alone would be
worse than nothing: refresh! POSTs before it saves, so the secret would still have gone out, the
provider would have rotated the single-use refresh token, and only then would the save have raised
RecordInvalid — a leak and a permanently unrefreshable credential in one pass.
The environment variables that matter
Section titled “The environment variables that matter”| Var | Used for |
|---|---|
API_KEYS | REST API auth (comma-separated) |
SUPERVISOR_PASSWORD | The operator HTTP Basic realm: /supervisor and the mutating POST /health/* actions. Unset or blank means both are closed, not open. |
SUPERVISOR_USERNAME | Optional; defaults to supervisor. |
APP_HOST | The MCP OAuth redirect URI. Defaults to localhost:3000, and picks http iff the host string contains “localhost”. |
RAILS_MASTER_KEY | Unlocks Rails credentials (mcp_oauth_clients, mcp_secrets) |
X_OAUTH_CLIENT_ID / _SECRET | X/Twitter token vending |
X_OAUTH_REDIRECT_URI | Where X sends the operator after consent, on both the consent request and the token exchange. Defaults to http://localhost:8080/callback, where nothing listens, so the operator pastes the redirect URL back into /supervisor. Set it to https://<APP_HOST>/supervisor/x_oauth/callback and the flow finishes on its own. Whatever you set must already be registered on the X app. See X (Twitter) is minted from /supervisor. |
ANTHROPIC_API_KEY | Local dev, when not using OAuth |