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).
| Column | Notes |
|---|---|
| Label | What you named it. |
| ID | Twelve hex characters. This is what you revoke by; it is not the token. |
| Role | viewer, operator or admin. |
| Created by | The principal that issued it. |
| Created | Relative time. |
| Last used | Relative time, or never. Recorded at most once a minute per token, so a busy client does not rewrite the state file on every request. |
| Expires | A 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.
| Field | Behaviour |
|---|---|
| Label | Required, 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. |
| Role | Viewer — read only, Operator — may commit config, Admin — may manage accounts. |
| Expiry | Never, 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:
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. Thewh_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.goAPIToken,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#
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/stagewith no extra ceremony, and a session cannot (agent/authhttp.goauthenticate). - A token in the URL is accepted on one route only.
/api/streamtakes?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 andRefererheaders.
Staging and committing with a token is the same two calls the UI makes:
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_totpapplies 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.goenrolmentRequired). - 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 beginningtoken: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.