Auth architecture
Zimmer has four separate authentication systems that share almost nothing. Understanding which is which is most of the battle.
1. Human → Zimmer: optional web sign-in
Section titled “1. Human → Zimmer: optional web sign-in”The web UI has one optional credential: web sign-in. A deployment turns it on
by setting ZIMMER_WEB_AUTH_GOOGLE_CLIENT_ID and its two companions in the secret store. Then every
browser surface asks for a Google Workspace account from an allowed domain, checked by the ID token’s
hd claim, followed by a code from an authenticator app. The sign-in lasts 90 days from last use.
The wall is WebSignInRequired, included by ApplicationController, the /supervisor panel, and
GoodJob’s dashboard; Action Cable checks the same cookie at connect. The REST API, /mcp, the
webhooks and /up sit outside it and keep their own credentials.
It is off by default, and while it is off there is no authentication of any kind in front of the web UI. 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, /supervisor, the Administrate admin panel,- the API keys page,
/settings/api_keys, - every action on
/health, including the destructivePOST /health/*maintenance buttons.
The last three are the ones where “anyone who can reach the host” costs the most:
/supervisorrendersmcp_oauth_credentials,mcp_oauth_pending_flowsandx_oauth_credentialsas editable resources, and their edit forms show plaintext access and refresh tokens, client secrets and PKCE verifiers. It is also where the X consent flow starts. A few columns are held back from the panel entirely, each in its dashboard’sDELIBERATELY_OMITTEDlist with the reason beside it, among themclaude_accounts.oauth_config(the tokens the whole fleet runs on),runtime_login_attempts.pasted_code,api_keys.token_digestandweb_identities.totp_secret./settings/api_keysmints and revokes the credential the REST API and the MCP endpoint take. See managing keys.- The
POST /health/*actions (cleanup_processes,retry_sessions,archive_old,enter_queue_recovery_mode,run_post_deploy_tasks,discard_queued_jobs,reschedule_queued_jobs) terminate processes, rewrite session rows in bulk, discard queued jobs, and halt the fleet’s demand-side job queues. Their REST twins onPOST /api/v1/health/*still take an API key. The web buttons take a signed-in browser when web sign-in is on, and nothing when it is off.
Until 2026-09-13 those three sat behind one shared HTTP Basic password, SUPERVISOR_PASSWORD. It
was removed so that the whole web UI has one auth posture. Nothing reads the variable any more,
so you can delete it from your deployment’s secrets. If your deploy workflow asserts that it is set,
remove that assert first, or the next deploy fails on the missing secret. Web sign-in is that one
posture’s credential, when a deployment wants one.
CSRF protection applies to every write on these three surfaces, so a page on another origin, open in your browser, cannot submit their forms for you.
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 hands the X-API-Key
header to ApiKey.authenticate. POST /mcp also takes the key as Authorization: Bearer. A key is
one of two things, and both are rows in api_keys:
- An
API_KEYSentry (comma-separated, from the environment). Every client that existed before #46 holds one of these, including every agent session, so they keep working. The first time an entry authenticates it gets a row namedAPI_KEYS <fingerprint>. The row authenticates only while the key is still in the variable, so removing an entry and redeploying retires it, as it always did. - A minted key, created on the API keys page. It is shown once, in the response that created it.
Zimmer’s iOS app is the one REST client without a key. It sends an OAuth access token issued to the
built-in zimmer-ios client, which the controllers it drives accept (see
the REST API).
The table holds a SHA-256 digest of each key and never the key. Every request re-reads API_KEYS
and re-finds the row. Nothing is cached across requests, so a revoke takes effect on the next
request in every Puma worker.
Each request is logged with the key’s name and never the key:
[api_key] POST /mcp authenticated as "API_KEYS 3f9a1c2e" (api_key_id=1, source=env)[api_key] GET /api/v1/sessions refused from 100.64.0.7: "laptop scripts" (api_key_id=4, source=minted) was revoked at 2026-09-11T20:14:03ZThe success line is INFO, so it stays in the container’s stdout, tagged with the request id. A
refusal that names a known key (revoked, or no longer in API_KEYS) is WARN, so it ships to obs.
last_used_at is stamped at most once a minute per key.
Every key has a grant, and there are three. api is the whole REST API and POST /mcp — every
API_KEYS entry is this, and so is a minted key unless the page was told otherwise. quick_router
opens exactly one endpoint, POST /api/v1/quick_router,
which creates a Quick Router session and returns its id and URL; it is the
browser extension’s credential, and it is narrow because of where it
lives — a browser profile on a machine that browses the open web. ApiKey.authenticate takes the
grant the calling controller honours and matches it exactly, so a quick_router key presented to
/api/v1/sessions or /mcp is refused the way a revoked key is (and logged at WARN, by name), and
an api key presented to the Quick Router ingest is refused too. The grant is a column the
comparison reads, not a promise about how the key is used.
The third, external_app, is a Zimmer plugin’s key. It opens
POST /mcp/external_app and /api/v1/external_app/..., which list and invoke the triggers on its
plugin’s allowlist, and nothing else. Such a key always belongs to one plugin
(api_keys.external_app_id, enforced by a check constraint as well as the model). It is minted from
the plugin’s page on Settings → Zimmer plugins, or with action_external_app, never from the API
keys form, which has no plugin to give it. The API keys page lists it with a link to its plugin, and
revoking it there works like revoking any key.
Every read of that column goes through ApiKey#effective_grant, which answers api when the column
is not on the table at all — the state a deployment is in between the code shipping and its migration
applying. A row with no grant predates the column, so it predates the only grant that is not api.
Skipping that made every authenticated request 500 in production on 2026-09-11; see
the deploy rule.
That reasoning runs one way only: it is safe while the column has never existed, and rolling the
migration back once quick_router keys exist would read every one of them as api — a full-API
credential sitting in a browser. Revoke the extension’s key before any such rollback.
POST /mcp takes one more credential: an OAuth access token from Zimmer’s own authorization server.
A remote MCP client such as a Claude.ai custom connector gets one after a signed-in person approves
it at /oauth/authorize, so it needs no key at all. It opens what an api key opens on /mcp, and
nothing on /api/v1. See Connecting to /mcp over OAuth.
What it still isn’t:
- No scoping within the API. Any valid
apikey can read, mutate, and delete every session, and trigger. Thequick_routerandexternal_appgrants are closed doors beside that one, not a permission system behind it. - No per-session identity. The agents share the deployment’s self-session key. See the limitation.
Managing keys
Section titled “Managing keys”/settings/api_keys (linked from Settings) lists every key by name, with where it came from, its
fingerprint, and when it was last used. It marks the key this deployment gives its own agent
sessions’ Zimmer MCP servers, but that is not the only entry agents hold: CliSpawnEnv does not
clear API_KEYS, so every session’s environment carries all of them. Only a minted key is out of an
agent’s reach. From there you can:
- Create a named key, choosing what it opens: Full API, or Quick Router only for the browser extension. Copy it from the page, because it is not shown again.
- Revoke a key. It is refused from the next request on. Revoking is a timestamp, not a delete, so
that a revoked
API_KEYSentry stays revoked while the key is still in the variable. - Restore a revoked key, if you revoked the wrong one. It authenticates again at once.
Like the rest of the web UI, the page has no credential in front of it beyond
web sign-in, when that is on. No API key and no MCP tool reaches what it does, on purpose. If an API key could mint keys it would issue itself new credentials, and if it
could revoke them any session could cut every other session off by revoking the key they share.
That keeps key management out of the tools a session is handed. The one REST sibling,
/api/v1/api_keys, writes
only for the iOS app’s sign-in on a connection that acts as you — a person, which a key is not; an
API key reading it sees fingerprints, never a key, and is refused every write. Without web
sign-in the page is not a wall: a session’s shell can reach it like anything else on the host.
The fingerprint is the first eight characters of the key’s SHA-256, so you can match a key you hold to its row:
printf %s "$KEY" | sha256sum | cut -c1-8These endpoints take a different credential instead:
POST /api/v1/quick_router— the browser extension’s ingest. It takes a key with thequick_routergrant (above) and refuses everyapikey, so the one credential a browser holds opens one write and no read.POST /mcp/external_app,GET /api/v1/external_app/triggersandPOST /api/v1/external_app/triggers/:id/invoke— a Zimmer plugin’s surface. They take a key with theexternal_appgrant and refuse every other key.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.
→ 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 and MCP auth (comma-separated). Each entry gets a named row the first time it is used, and can be revoked on /settings/api_keys. Minted keys live only in the database. |
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 |
ZIMMER_WEB_AUTH_* | Web sign-in: the Google OAuth client ID and secret, the allowed Workspace domains, the second-factor mode, and how long sessions and trusted browsers last. Setting ZIMMER_WEB_AUTH_GOOGLE_CLIENT_ID turns the wall on |