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#
| Kind | Credential | Proved by | Where it lives |
|---|---|---|---|
session | An opaque cookie value | The session record, plus a CSRF header on every mutation | state.json in the agent's data directory |
token | wh_… in an Authorization: Bearer header | A constant-time comparison against the stored SHA-256 | Only the hash is stored |
bootstrap | The break-glass token, in the same header | A constant-time comparison against --admin-token | A 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.
curl -sk -H "Authorization: Bearer wh_..." https://<router>:8443/api/systemAn 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):
| Limiter | Budget | Why |
|---|---|---|
| Per source address | 8 failures in 5 minutes | The ordinary case. |
| Per account name | 20 failures in 30 minutes | Guessing one password costs attempts rather than addresses — an attacker holding a whole IPv6 range walks past an address limit. |
| Site-wide | Past 200 failures in 5 minutes, every attempt is slowed by 250 ms | It 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#
- Read plane, write plane, admin plane — what a principal may then do.
- The audit log — where the principal's name ends up.
- Account — your own password, second factor and live sessions.
- Users — the accounts screen.
- API tokens — issuing and revoking one.
- Authentication — the same ground as an API reference.
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