Wheelhouse docs

Accounts, sessions and tokens#

Every request the agent handles carries a principal: a name, a role, and how it was proved. There are three kinds. A person in a browser holds a session, a script holds an API token, and an operator who has locked themselves out of both can hold the break-glass token. The router's own shell accounts are a separate thing entirely and do not appear here.

The three principals#

KindCredentialProved byWhere it lives
sessionAn opaque cookie valueThe session record, plus a CSRF header on every mutationstate.json in the agent's data directory
tokenwh_… in an Authorization: Bearer headerA constant-time comparison against the stored SHA-256Only the hash is stored
bootstrapThe break-glass token, in the same headerA constant-time comparison against --admin-tokenA 0600 file the operator creates

Authentication tries the cookie first and falls through to a bearer token when the cookie names no live session — so a client with a stale cookie jar and a perfectly good token is not refused with a message about sessions (agent/authhttp.go, authenticate).

Accounts#

An account has a name, a role, a password hash, and optionally a second factor and a single-sign-on identity (agent/auth.go, User).

Passwords are Argon2id over a 16-byte random salt: 64 MiB of memory, 3 passes, 2 threads, a 32-byte key — the OWASP baseline, about 60 ms on the class of CPU this is built for. Only the hash and the salt are stored.

Any non-empty password is accepted. The only refusals are an empty password and one over 1024 characters (agent/authhttp.go, validatePassword). That is deliberate: a complexity rule pushes people toward predictable substitutions, so the product advises rather than refuses, and puts its effort into rate limiting instead.

Changing a password signs out that account's other sessions and clears the must change flag. A wrong current password answers 403, not 401 — the session is valid, the guess is not, and a 401 would have sent the browser to the login screen over a typo.

The first account is created on first start. admin, with a generated password logged exactly once and written to a 0600 file inside the data directory so the console banner can show it. The account is flagged must change until the password is replaced, and the file is removed the moment it is (agent/main.go, bootstrapAdmin and clearInitialPassword).

Sessions#

A session is a random identifier in a cookie and a record on the router. The cookie is HttpOnly, SameSite=Strict, and Secure whenever the request arrived over HTTPS — decided from the request rather than from a flag, because marking a cookie Secure on a plain-HTTP origin makes the browser drop it and a successful login come back as "unauthorized" with no clue why (agent/authhttp.go, requestIsHTTPS).

A cookie alone can never change configuration. Every mutating request must also echo that session's CSRF token in the X-Wheelhouse-CSRF header. Cookies are sent automatically by the browser; the header is not, which is the whole mechanism. A bearer token skips the requirement, because it is never sent automatically — which is why a script should use a token rather than a cookie jar.

The default lifetime is 12 hours (session_ttl_minutes, 720 — agent/store.go), adjustable in agent settings. Expired sessions are pruned whenever the store is written. You can list and revoke your own live sessions from the Account page, or through GET and DELETE /api/auth/sessions.

A session also records how it was opened — password or oidc — and, for a single-sign-on session, the ID token, so that signing out can offer the provider's own end-session URL.

API tokens#

For scripts, Terraform and CI. A token carries a role of its own, may be given an expiry, and only its SHA-256 is stored: the plaintext is shown once, at creation, and never again. Tokens begin wh_, which makes a leaked one findable by a secret scanner and greppable in a log.

bash
curl -sk -H "Authorization: Bearer wh_..." https://<router>:8443/api/system

An expired token is treated as no token at all (agent/auth.go, findToken), and its last use is recorded so an unused token is visible as unused.

The break-glass token#

--admin-token-file names a file holding a token that authenticates as admin with no account behind it. It exists so that an operator locked out of the UI — or a bootstrap script, or wheelhouse-agent apply --agent-url — can still reach the API.

It has no default. The shipped systemd unit points at /config/wheelhouse/admin-token, and nothing creates that file, so a default install has no break-glass token: the agent notes the file is missing and carries on. Create one deliberately if you want one, and treat it as the full admin credential it is.

Two-factor#

TOTP as in RFC 6238: a 160-bit secret, SHA-1, six digits, a 30-second step, and the current step plus one on either side accepted to cover clock skew between the router and the phone (agent/auth.go).

Enrolment is two calls. POST /api/auth/totp/begin returns a pending secret and its otpauth:// URI; the secret does nothing until POST /api/auth/totp/confirm proves a live code, at which point it is promoted. A mis-scanned code therefore cannot lock anybody out.

require_totp in agent settings makes it mandatory. Refusing a password login outright would brick the box, because enrolment itself needs a session — so the session that is issued is marked enrolling and can reach nothing but the enrolment routes until a code proves the secret (agent/authhttp.go, requireRole and handleLogin).

What protects the login#

Three rate limiters, because each stops a different attack (agent/security.go):

LimiterBudgetWhy
Per source address8 failures in 5 minutesThe ordinary case.
Per account name20 failures in 30 minutesGuessing one password costs attempts rather than addresses — an attacker holding a whole IPv6 range walks past an address limit.
Site-widePast 200 failures in 5 minutes, every attempt is slowed by 250 msIt never refuses: a real operator has to be able to sign in during an attack.

Two more details of the login path are deliberate. A login for an account that does not exist still spends the time an Argon2id hash would take, so timing does not leak which names exist; and no such user and wrong password return the same message, because anything else is a username oracle.

The address a limiter counts against, and the one the audit log records, comes from the connection unless the agent was started with --trust-proxy — and even then it is the last element of X-Forwarded-For, the one your proxy appended. Taking the first let a client forge its own address straight past the flag.

Single sign-on, in one paragraph#

The agent can act as an OIDC relying party: authorization code with PKCE, one provider, configured by flags on the unit rather than through the UI. Identities are linked by the provider's sub claim and never by email unless --oidc-link-by-email is set, and even then only when the provider asserts email_verified. Password, token and break-glass logins keep working when it is on, because a router that can only be entered through an identity provider is one outage from being unmanageable. The detail is in ADR-002 and on Single sign-on.

See also#

Checked against#

agent/auth.go · agent/authhttp.go · agent/security.go · agent/store.go · agent/main.go · packaging/wheelhouse-agent.service · docs/security.md · docs/deploy.md · docs/adr/002-oidc-client.md

Updated 2026-09-02 concepts accounts sessions tokens totp