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.

Web interface users and router accounts#

The accounts on this page are the web interface's own users, kept by the agent and managed on Admin → Users. They are not the router's accounts.

The router has accounts of its own, for its console and SSH. Those are router configuration: they are on System → Settings tab → Router accounts, a change to them goes to the pending changes and is applied like any other, and they do not appear anywhere on this page. The two lists are separate: an account in one is not an account in the other, even when the names match.

The three principals#

KindCredentialProved byWhere it is kept
sessionAn opaque cookie valueThe session record, plus a CSRF header on every request that changes somethingThe agent's state file, in its data directory
tokenwh_… in an Authorization: Bearer headerA constant-time comparison against the stored SHA-256Only the hash is kept
bootstrapThe break-glass token, in the same headerA constant-time comparison against the token the agent was givenA file readable by root alone, which 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 and a good token is not refused with a message about sessions.

Accounts#

An account has a name, a role, a password hash, and optionally a second factor and a single sign-on identity. It also keeps its preferences — Advanced mode and the pages pinned to the top of the sidebar — so they follow the person from one browser to another.

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 processor this is built for. Only the hash and the salt are kept.

Any password that is not empty is accepted. The only refusals are an empty password and one over 1,024 characters. There is no complexity rule; the product limits failed attempts instead.

Changing a password signs out that account's other sessions, and clears the flag that asks for a new password. A wrong current password answers 403, not 401: the session is valid and the guess is not, and a 401 would have sent the browser to the sign-in screen over a typo.

The first account is created on first start: admin, with a generated password written once to the agent's log and to a file readable by root alone in its data directory, so the console banner can show it. The account has to choose a new password at its first sign-in, and the file is removed the moment it does. On a router set up by the installer, the installer makes that change itself, to the password chosen during installation.

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. That is decided from the request rather than from a setting: marking a cookie Secure on a plain-HTTP address makes the browser drop it, and a successful sign-in then comes back as unauthorised.

A cookie alone can never change anything. Every request that changes something must also carry that session's CSRF token in the X-Wheelhouse-CSRF header. A browser sends cookies by itself; it does not send the header, which is the whole mechanism. A bearer token needs no such header, because it is never sent by itself — which is why a script should use a token rather than a cookie.

The default lifetime is 12 hours, set as Session lifetime on Admin → Agent settings; a new lifetime applies to sessions opened after the change. Expired sessions are removed whenever the store is written. You can see your own sessions on the Account page and sign any of them out, or use GET and DELETE /api/auth/sessions.

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

API tokens#

For scripts, Terraform and CI, issued on Admin → API tokens. A token carries a role of its own, may be given an expiry, and only its SHA-256 is kept: the token is shown once, when it is issued, and never again. Tokens begin wh_, which makes a leaked one easy to find in a log and to catch with a secret scanner.

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

An expired token is treated as no token at all, and its last use is recorded, so an unused token shows as unused.

The break-glass token#

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

It has no default. The installed service looks for /config/wheelhouse/admin-token, and nothing creates that file, so a new router has no break-glass token: the agent notes that 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 authentication#

TOTP as RFC 6238 describes it: a 160-bit secret, SHA-1, six digits, a 30-second step, and the steps either side of the current one accepted as well, to allow for a difference between the router's clock and the phone's.

Setting it up is two requests. POST /api/auth/totp/begin returns a pending secret and its otpauth:// address; the secret does nothing until POST /api/auth/totp/confirm proves a live code, at which point it becomes the account's secret. A mis-scanned code therefore cannot lock anybody out.

Require two-factor authentication, on Admin → Agent settings, makes it mandatory. Setting it up needs a session, so an account without it still signs in with its password; the session it gets can reach nothing but the setup until a code proves the secret.

Recovery codes#

Turning two-factor authentication on also issues ten single-use recovery codes, returned in that one response and shown exactly once. They are the second thing that satisfies the second factor: the agent checks a code from the authenticator and an unused recovery code in one place, so the two can never drift into meaning different things.

They are kept beside the state file, readable by root alone, and only their hashes are kept — a used code keeps its place, so the count still reads 3 of 10 left. Issue new codes on the Account page replaces the whole set and asks for the second factor first, because a printed list is a credential and an unattended browser should not be able to make one. Turning two-factor authentication off, or an admin clearing it, discards them.

Losing the device#

Three answers, in order of cost:

  1. A recovery code. POST /api/auth/login takes one in a recovery field. The browser's sign-in screen does not yet offer that field — it asks for six digits — so today this is an API client's way back.
  2. An admin clears it, with Clear two-factor authentication in the user's panel on Admin → Users. That removes the secret and the codes, ends the account's sessions, and does not turn off the requirement: with it on, the account is asked to set up two-factor authentication again at its next sign-in. An admin cannot clear their own this way, and nothing happens when there is nothing to clear.
  3. The console. wheelhouse-rescue totp --user NAME, for the day every admin has lost their phone at once — Locked out of the UI.

The break-glass token and an admin API token can make the admin's request too. That is deliberate: neither is an account, so neither has a second factor of its own to get round.

What protects the sign-in#

Three limits on failed attempts, each stopping a different attack:

LimitAllowanceWhy
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 a per-address limit.
Across the routerPast 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 signing in. An attempt for an account that does not exist still spends the time an Argon2id hash would take, so the timing does not reveal which names exist; and no such user and wrong password get the same message, because anything else would reveal the names.

The address a limit counts against, and the one the audit log records, is the connection's own, unless the agent was started to trust a proxy in front of it — and even then it is the last address in X-Forwarded-For, the one the proxy added, not the first, which a client can forge.

Single sign-on, in one paragraph#

The agent can sign people in through one OIDC identity provider, with the authorisation code flow and PKCE, set up by options on the agent's service rather than in the web interface. 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. Passwords, tokens and the break-glass token keep working when it is on, because a router that can only be entered through an identity provider is one outage from being unmanageable. Single sign-on has the detail.

See also#

Updated 2026-09-18 concepts accounts sessions tokens totp