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 can never authorise a mutation — the browser must also echo the session's CSRF token in a header — which is why a script should use a token instead of driving 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 no longer short-circuits — a client holding a stale cookie jar and a good token falls through to the token. The CSRF requirement stays bound to the cookie path, where it belongs.

token: and admin-token are reserved names: the agent refuses to create an account called admin-token or anything starting with token:, so the audit log can never be ambiguous about 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. Marking it Secure on a plain-HTTP origin would make the browser drop it and turn a successful login into an unexplained "unauthorized".

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, so 90 seconds of clock skew between the router and the phone is survivable.

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. An unlabelled token is unrevocable in practice.
roleRequired; one of viewer, 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, so a busy automation client does not rewrite the state file on every call.

The break-glass token#

--admin-token (or --admin-token-file) is a single admin-role credential with no account behind it. It exists so an operator locked out of the UI, or a bootstrap script, can still reach the API — and it is what wheelhouse-agent apply --agent-url uses 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 simply means there is no break-glass token.

The five unauthenticated routes#

RouteReturns
GET /health{"status":"ok"}. Nothing else — not the version, deliberately.
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.
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.

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, because a real operator has to be able to sign in during an attack

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, so the endpoint is not a user-name oracle.

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#

Checked against#

agent/authhttp.go (authenticate, requireRole, handleLogin, handleAuthStatus, setSessionCookie, enrolmentPaths), agent/auth.go (Role, APIToken, Session, verifyTOTP, tokenFingerprint, newSession), agent/admin.go (handleCreateToken, validUserName), agent/security.go (loginLimiter, userLimiter, globalFailures, safeCompare), agent/oidc.go, agent/store.go (Settings), docs/security.md, SUPPORT.md.

Updated 2026-09-02 api authentication csrf tokens