Wheelhouse docs

Authenticating#

Every route below /api/ except five resolves the request to a principal before the handler runs. There are three ways to become one: a session cookie (browsers), a Bearer token (scripts), and the break-glass token (an operator locked out of the UI). A cookie alone cannot authorise a mutation: the browser must also echo the session's CSRF token in a header. A script should use a token rather than the login endpoint.

The three credentials#

KindSent asPrincipal nameRole from
SessionCookie: wheelhouse_session=<id> plus X-Wheelhouse-CSRF: <token> on mutationsthe account namethe account
API tokenAuthorization: Bearer wh_…token:<label>the token
Break-glassAuthorization: Bearer <the --admin-token value>admin-tokenalways admin

The order is fixed: a session cookie is tried first, then a Bearer token. A dead cookie does not short-circuit: a client holding a stale cookie jar and a good token falls through to the token. The CSRF requirement applies to the cookie path only.

token: and admin-token are reserved names. The agent refuses to create an account called admin-token or anything starting with token:, so an audit entry always names which of the three acted.

Signing in from a browser#

bash
curl -sk -c cookies.txt "$R/api/auth/login" \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"…"}'
json
{
  "user": "admin",
  "role": "admin",
  "csrf": "…",
  "totp_enabled": false,
  "must_change_password": true,
  "expires_at": "2026-09-03T09:14:22Z"
}

The cookie is HttpOnly, SameSite=Strict, Path=/, and Secure whenever the client is on HTTPS — decided from the request, not from a flag, so an agent behind a TLS-terminating proxy still marks it.

Session lifetime comes from the session_ttl_minutes setting, 720 minutes (12 hours) by default, adjustable between 5 minutes and 30 days.

Two-factor#

If the account has TOTP enabled, the first call answers:

json
{"error": "two-factor code required", "totp_required": true}

Repeat the login with "totp":"123456". Codes are RFC 6238, SHA-1, six digits, 30-second step, and the current step plus one on either side is accepted, which tolerates 90 seconds of clock skew between the router and the phone.

If the require_totp policy is on and the account has not enrolled, the login succeeds and returns "totp_enrolment_required": true. That session can reach exactly four routes — /api/auth/totp/begin, /api/auth/totp/confirm, /api/auth/me and /api/auth/logout — and everything else answers 403 with the same flag, until a code proves the secret.

CSRF#

Any method other than GET, HEAD or OPTIONS made with a session cookie must carry the session's CSRF token:

bash
CSRF=$(curl -sk -b cookies.txt "$R/api/auth/me" | python3 -c 'import json,sys;print(json.load(sys.stdin)["csrf"])')

curl -sk -b cookies.txt "$R/api/discard" -X POST \
  -H "X-Wheelhouse-CSRF: $CSRF"

A missing or wrong header is 401 {"error":"missing or invalid CSRF token"}. The comparison is constant-time. The token is returned by POST /api/auth/login and by GET /api/auth/me, and it lives as long as the session does.

Using an API token#

Create one as an admin — the plaintext is shown exactly once and only its SHA-256 is stored:

bash
curl -sk -b cookies.txt "$R/api/admin/tokens" -X POST \
  -H "X-Wheelhouse-CSRF: $CSRF" -H 'Content-Type: application/json' \
  -d '{"label":"terraform","role":"operator","expires_days":90}'
json
{
  "id": "9f2a7c1b4e05",
  "label": "terraform",
  "role": "operator",
  "token": "wh_…",
  "expires_at": "2026-12-01T09:14:22Z",
  "note": "copy this now — it is not recoverable"
}

Then:

bash
curl -sk "$R/api/staged" -H "Authorization: Bearer wh_…"
FieldRule
labelRequired. It is what a token is revoked by.
roleRequired; one of viewer, approver, operator, admin.
expires_daysOptional. Omit or pass 0 for a token that never expires.
The token itselfwh_ plus 48 hex characters. The prefix makes a leaked token greppable in logs and secret scanners.

Last use is recorded at most once a minute per token.

The break-glass token#

--admin-token (or --admin-token-file) is a single admin-role credential with no account behind it. An operator locked out of the UI, or a bootstrap script, can use it to reach the API, and wheelhouse-agent apply --agent-url uses it to stage into a running agent. It is compared in constant time. It is optional: the shipped unit names the file, and a missing file means there is no break-glass token.

The six unauthenticated routes#

RouteReturns
GET /health{"status":"ok","version":"…"}. The agent's own version and nothing else about the box — no host name, no licence state, no router reachability.
GET /api/auth/statusWhether an account exists, whether TLS is on, whether the router is licensed, whether TOTP is required, and whether single sign-on is offered, with its label. Not the issuer URL.
GET /api/oidc/statusThe same, plus the issuer's readiness and the redirect URI. Still not the issuer URL: a discovery failure has the hostname masked out of its detail.
GET /api/oidc/loginStarts the single sign-on redirect.
GET /api/oidc/callbackThe provider's return leg. No session exists until an ID token verifies.
POST /api/auth/loginThe login itself, behind all three rate limiters.

Everything else, including GET /metrics unless --metrics-public is set, needs a principal.

Tokens in the URL#

A token may be passed as ?token= on /api/stream only, because the browser WebSocket API cannot set headers. On every other route a ?token= parameter is refused with a message that says so rather than a bare 401:

json
{"error": "a token in the URL is accepted only on /api/stream; send it as an Authorization: Bearer header"}

Login rate limiting#

Three limiters guard POST /api/auth/login, and only that route.

LimiterBudgetWindowOn trip
Per source address8 failures5 minutes429, before the body is read
Per account name20 failures30 minutes429
Site-wide200 failures5 minutesEvery attempt sleeps 250 ms. It slows; it never refuses.

A successful login clears the first two for that address and account. A failed login never distinguishes "no such user" from "wrong password", and spends the same time hashing either way.

Single sign-on#

When --oidc-issuer and --oidc-client-id are configured the login screen also offers the provider. The flow is authorization code with PKCE; the session that comes out is an ordinary session with provider: "oidc" recorded on it, and logging out returns a logout_url when the provider supports RP-initiated logout. The only link the agent trusts between an identity and an account is the provider's sub claim; linking by email is opt-in (--oidc-link-by-email) and honoured only when the provider asserts email_verified.

See also#

Updated 2026-09-02 api authentication csrf tokens