Wheelhouse docs

API tokens#

Administration → API tokens issues the credentials that scripts, CI jobs and configuration tools use. A token carries one of the three roles, optionally an expiry, and the label you gave it; only its SHA-256 hash is stored, and the plaintext is shown exactly once. The page and every route behind it are admin-only. Nothing here is staged — issuing and revoking take effect at once, and both are audited.

This is how the product keeps the promise that anything the UI can do, curl can do: the UI itself is a client of the same API, and a token is the same principal a session is, minus the browser.

The table#

GET /api/admin/tokens returns the metadata and never the secret (agent/admin.go handleListTokens).

ColumnNotes
LabelWhat you named it.
IDTwelve hex characters. This is what you revoke by; it is not the token.
Roleviewer, operator or admin.
Created byThe principal that issued it.
CreatedRelative time.
Last usedRelative time, or never. Recorded at most once a minute per token, so a busy client does not rewrite the state file on every request.
ExpiresA date, or never. Shown in the warning colour inside the last seven days.
Revoke, which arms and then needs a second click.

Issuing one#

+ Issue token asks for three things.

FieldBehaviour
LabelRequired, both in the form and in the agent, which answers 400 with an unlabelled token is unrevocable in practice. Name the thing that will hold it — terraform-prod, backup-cron — not the person.
RoleViewer — read only, Operator — may commit config, Admin — may manage accounts.
ExpiryNever, 30 days, 90 days or 1 year. The field's hint is the argument for not choosing Never: a token that never expires is a token you will forget you issued.

Choosing admin raises a warning in the panel: an admin token can create users and issue further tokens, and very few automations need that. Operator is almost always the right answer — it is everything that changes configuration, and nothing that changes who may.

When it is issued, a panel appears above the table with the plaintext and a curl line built for this router:

bash
curl -H "Authorization: Bearer wh_…" https://<router>:8443/api/config | jq .

Copy it then. The response is the only time the plaintext exists anywhere outside the holder's hands; a lost token is revoked and reissued, not recovered.

What a token looks like, and what is kept#

  • The plaintext is wh_ followed by 48 hexadecimal characters — 24 random bytes. The wh_ prefix is there so a leaked token is greppable in a log and findable by a secret scanner.
  • What is stored is the SHA-256 of the plaintext, its id, its label, its role, who created it, when, when it was last used and when it expires (agent/auth.go APIToken, tokenFingerprint).
  • Lookup compares hashes in constant time, and an expired token is treated as no token at all rather than as a rejected one.

The state file that holds all of this is mode 0600 in the agent's data directory.

Using one#

bash
R=https://<router>:8443
T=wh_…

curl -sk -H "Authorization: Bearer $T" $R/api/firewall/rules | jq .

A token authenticates on the Authorization header. Two consequences worth knowing:

  • Tokens skip CSRF. The CSRF requirement is bound to the cookie path, where it belongs: a browser sends cookies automatically, a script sends a header on purpose. So a token can POST /api/stage with no extra ceremony, and a session cannot (agent/authhttp.go authenticate).
  • A token in the URL is accepted on one route only. /api/stream takes ?token= because a browser WebSocket cannot set headers. Everywhere else the agent refuses it, and says why — a credential in a URL ends up in proxy logs, browser history and Referer headers.

Staging and committing with a token is the same two calls the UI makes:

bash
curl -sk -X POST $R/api/stage -H "Authorization: Bearer $T" \
  -H 'Content-Type: application/json' \
  -d '[{"op":"set","path":["firewall","ipv4","forward","filter","rule","110","action","accept"]}]'
curl -sk -X POST $R/api/commit -H "Authorization: Bearer $T" \
  -H 'Content-Type: application/json' -d '{"confirm_minutes":2}'
curl -sk -X POST $R/api/commit/confirm -H "Authorization: Bearer $T"

What a token may do#

Exactly what its role may do — see roles. The same route table, the same 402 when the agent is unlicensed and the request would change configuration.

Two differences from a session, both deliberate:

  • A token is never gated by the two-factor enrolment policy. require_totp applies to browser sessions. A token or the break-glass credential is the way back when somebody has locked themselves out of the UI (agent/authhttp.go enrolmentRequired).
  • A token is its own principal. It appears in the audit log as token:<label>, not as the person who issued it, which is why the label matters and why account names beginning token: are refused.

Revoking#

Revoke arms first and needs a second click, because revocation is immediate and there is no way to put the secret back. Whatever was authenticating with that token stops working on the next request. DELETE /api/admin/tokens/{id} answers 404 for an id that is not there, and the revocation is audited as revoke-token with the id.

Tokens are independent of accounts. Deleting the account that created a token does not revoke it; the token keeps its own role until it is revoked or expires. Audit both lists when somebody leaves.

The break-glass token is not one of these#

The agent's --admin-token-file flag names a file holding a token that authenticates as admin with no account behind it. It is not issued here, does not appear in this table, and cannot be revoked from the UI — you delete the file and restart the agent. The shipped unit points at /config/wheelhouse/admin-token and nothing creates that file, so a default install has no break-glass token at all (agent/authhttp.go, docs/security.md).

See also#

  • Roles — what each role reaches, from the route table.
  • Users — accounts, which are the other kind of principal.
  • Account — sessions, CSRF and the cookie a browser uses instead.
  • Authenticating — the header, the CSRF rule and the stream exception.
  • The HTTP API — what a token can call.
  • The licence gate — why a write can answer 402.

Checked against#

ui/src/pages/Tokens.tsx, ui/src/lib/api.ts, agent/admin.go, agent/auth.go, agent/authhttp.go, agent/main.go, docs/security.md.

Updated 2026-09-02 manual tokens api automation