Skip to content

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 failure
end

/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/* actionscleanup_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.

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/:token and GET /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.

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

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 you op-inject a token into and then shell out to;
  • a remote MCP server — an http / streamable-http / sse endpoint 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.

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:

WhereWhat it does
McpOauthCredentialRefuses 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
McpOauthPendingFlowRefuses to store one, so the initial code exchange cannot leak it either
McpOauthService#post_formRaises InsecureEndpoint rather than opening a cleartext connection
McpOauthService#post_jsonThe 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.

VarUsed for
API_KEYSREST API auth (comma-separated)
SUPERVISOR_PASSWORDThe operator HTTP Basic realm: /supervisor and the mutating POST /health/* actions. Unset or blank means both are closed, not open.
SUPERVISOR_USERNAMEOptional; defaults to supervisor.
APP_HOSTThe MCP OAuth redirect URI. Defaults to localhost:3000, and picks http iff the host string contains “localhost”.
RAILS_MASTER_KEYUnlocks Rails credentials (mcp_oauth_clients, mcp_secrets)
X_OAUTH_CLIENT_ID / _SECRETX/Twitter token vending
X_OAUTH_REDIRECT_URIWhere 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_KEYLocal dev, when not using OAuth