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 /supervisor)
Section titled “1. Human → Zimmer: there is no authentication (except /supervisor)”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,/quotas(including the OAuth login flow),- the GoodJob dashboard at
/jobs.
The one exception: /supervisor is behind HTTP Basic
Section titled “The one exception: /supervisor is behind HTTP Basic”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. That is the one surface where “anyone who reaches
the host” is too generous, so it gets a second wall —
app/controllers/supervisor/application_controller.rb:
before_action :authenticate_supervisor
def authenticate_supervisor expected_password = ENV[PASSWORD_ENV].to_s return request_http_basic_authentication(REALM) if expected_password.empty? # ...constant-time compare of username and passwordendOne 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.
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.
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 skip it entirely:
POST /api/v1/elicitationsandGET /api/v1/elicitations/:id— required by the MCP fallback-elicitation protocol, since the MCP child process has no key.
3. Zimmer → the agent vendor
Section titled “3. Zimmer → the agent vendor”A pool of accounts (ClaudeAccount — misleadingly named; it serves both runtimes, discriminated
by a runtime column) with automatic OAuth refresh and automatic rotation when one hits its quota.
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.
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”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 /supervisor HTTP Basic realm. Unset or blank means the panel is 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 | The callback XOauthBootstrap sends on both the consent request and the token exchange. Defaults to http://localhost:8080/callback; whatever you set must already be registered on the X app. |
ANTHROPIC_API_KEY | Local dev, when not using OAuth |