Skip to content

Auth architecture

Zimmer has four separate authentication systems that share almost nothing. Understanding which is which is most of the battle.

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 destructive POST /health/* maintenance buttons.

The last three are the ones where “anyone who can reach the host” costs the most:

  • /supervisor renders mcp_oauth_credentials, mcp_oauth_pending_flows and x_oauth_credentials as 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’s DELIBERATELY_OMITTED list with the reason beside it, among them claude_accounts.oauth_config (the tokens the whole fleet runs on), runtime_login_attempts.pasted_code, api_keys.token_digest and web_identities.totp_secret.
  • /settings/api_keys mints 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 on POST /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.

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_KEYS entry (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 named API_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:03Z

The 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 api key can read, mutate, and delete every session, and trigger. The quick_router and external_app grants 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.

/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_KEYS entry 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:

Terminal window
printf %s "$KEY" | sha256sum | cut -c1-8

These endpoints take a different credential instead:

  • POST /api/v1/quick_router — the browser extension’s ingest. It takes a key with the quick_router grant (above) and refuses every api key, so the one credential a browser holds opens one write and no read.
  • POST /mcp/external_app, GET /api/v1/external_app/triggers and POST /api/v1/external_app/triggers/:id/invoke — a Zimmer plugin’s surface. They take a key with the external_app grant and refuse every other key.
  • 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.

→ 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 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_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
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