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 /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 password
end

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.

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.

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/elicitations and GET /api/v1/elicitations/:id — required by the MCP fallback-elicitation protocol, since the MCP child process has no key.

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.

Agent harness credentials

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.

MCP server OAuth

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.

VarUsed for
API_KEYSREST API auth (comma-separated)
SUPERVISOR_PASSWORDThe /supervisor HTTP Basic realm. Unset or blank means the panel is 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_URIThe 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_KEYLocal dev, when not using OAuth