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#
| Kind | Sent as | Principal name | Role from |
|---|---|---|---|
| Session | Cookie: wheelhouse_session=<id> plus X-Wheelhouse-CSRF: <token> on mutations | the account name | the account |
| API token | Authorization: Bearer wh_… | token:<label> | the token |
| Break-glass | Authorization: Bearer <the --admin-token value> | admin-token | always 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#
curl -sk -c cookies.txt "$R/api/auth/login" \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"…"}'{
"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:
{"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:
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:
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}'{
"id": "9f2a7c1b4e05",
"label": "terraform",
"role": "operator",
"token": "wh_…",
"expires_at": "2026-12-01T09:14:22Z",
"note": "copy this now — it is not recoverable"
}Then:
curl -sk "$R/api/staged" -H "Authorization: Bearer wh_…"| Field | Rule |
|---|---|
label | Required. It is what a token is revoked by. |
role | Required; one of viewer, approver, operator, admin. |
expires_days | Optional. Omit or pass 0 for a token that never expires. |
| The token itself | wh_ 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#
| Route | Returns |
|---|---|
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/status | Whether 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/status | The 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/login | Starts the single sign-on redirect. |
GET /api/oidc/callback | The provider's return leg. No session exists until an ID token verifies. |
POST /api/auth/login | The 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:
{"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.
| Limiter | Budget | Window | On trip |
|---|---|---|---|
| Per source address | 8 failures | 5 minutes | 429, before the body is read |
| Per account name | 20 failures | 30 minutes | 429 |
| Site-wide | 200 failures | 5 minutes | Every 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#
- The HTTP API — what holds for every endpoint.
- Role matrix — what each role may call.
- Status codes
- Audit entries — every login, failed login and role denial is recorded.
- Every flag — the
--oidc-*and--admin-token*flags. - Accounts and sessions
- API tokens — the screen that issues them.
- Single sign-on