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#
| 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 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#
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. 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:
{"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:
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. An unlabelled token is unrevocable in practice. |
role | Required; one of viewer, 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, 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#
| Route | Returns |
|---|---|
GET /health | {"status":"ok"}. Nothing else — not the version, deliberately. |
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. |
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. |
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, 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#
- 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
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.