Problem
The platform's API has one kind of credential today: an API key. A key is an id and a secret that a program trades for a short-lived token before it calls anything, and trades again a quarter of an hour later. That is the right shape for a production server: the secret stays on the server, the token that travels expires fast, and the trade is the standard OAuth 2.0 client-credentials grant every HTTP library knows.
It is the wrong shape for the first ten minutes. A developer who wants to see what
GET /v1/me answers has to make a key, copy two values, run a token request, copy the
token, then make the call. The API reference on the docs site had a bearer field and
nothing to put in it. GitHub, OpenAI and Stripe all start a developer with one string
pasted into one header; we started them with a protocol.
Three things were asked for together: a token you paste and use, made in one click from the place you are reading; a console that manages tokens and keys properly (search, filters, pages, a dialog that fits the screen); and a way for a developer who does not want to read the scope list to say what they need and get the token for it.
Proposal
An API token is one bearer token. You make it in the console or in the docs, it is
shown once, and every call carries it as Authorization: Bearer <token>. It holds the
scopes you chose from the same catalogue keys use, and it lives 7, 30, 90 or 365 days.
Keys stay, for servers that should hold a secret and not a long-lived token.
Nothing that could recreate it is stored. Not the token, not a hash of it. The platform keeps its name, its scopes, who made it, when it expires and when it was last used. The token itself is made by the identity provider that already makes every other token on the platform:
Because it is the same kind of token a key's trade produces, the gateway, the per-scope checks, the per-key limits, server-sent events, the WebSocket and MCP accept it without a line changed. Usage is counted per token, so the console's usage page shows each one by name.
Revocation within seconds. A signed token cannot be recalled once it is out: until
it expires, its signature is valid. So revocation is enforced where every call is
already counted. The gateway reports each call's key or token to the accounts service
in short batches; the answer to each batch names the ones among them that are revoked,
expired or gone, and the gateway refuses their calls from then on with
401 unauthenticated. A revoked token stops working within one reporting interval, a
few seconds. The same now holds for a revoked key's outstanding tokens, which before
lived out their quarter of an hour.
A diagram is drawn here in the RFCs product; this page does not show diagrams yet.
One click in the docs. Every page of the API reference opens with a credential bar. Signed out, it offers to sign in and come back. Signed in, it makes a test token for your own account (every read scope, seven days), keeps it in that browser, and puts it in the request, so the "Send" button works and the code samples show the real header. The token is also listed in the console, where it can be revoked.
Management in the console. Tokens and keys sit side by side, with search by name or id, filters by status (active, expiring within seven days, expired, revoked), sorting by creation, last use or expiry, and pages. The dialog that shows a new token or key once fits on a laptop screen without scrolling, with a copy button, a download, and a command that works as pasted.
Describe what you need. In the console you can write, in plain words, what the token is for: "read the radar from a weekly cron job". Our own model, the one the Lab runs on our own hardware, proposes a name, the scopes with a reason for each, and a lifetime, and says plainly if part of the request is something no scope grants ("writing to the radar"). The proposal is labelled as written by a model, and nothing is made until you press Create. Every field of the answer is checked against the catalogue before you see it: a scope the model invents is not offered, a lifetime that is not on the list becomes the nearest one that is. The description goes to our model and nowhere else, and it is not logged.
A diagram is drawn here in the RFCs product; this page does not show diagrams yet.
Alternatives considered
- An opaque token with our own prefix, checked at the gateway. The familiar shape
(
iohr_pat_…), and secret scanners recognise a prefix. It needs a new check on every call, the gateway asking the accounts service whether the string is a live token, and every scope rule written a second time for it. That puts the accounts service on the path of every request for a property we get from the existing signature check. We accept the cost: the token is long and carries no prefix of ours. - Tokens stored encrypted, so they can be shown again. It would let the docs pick up a token made in the console on another machine. It also means a leak of the database and its key is a leak of every customer's working credential. Shown once, never stored, is what GitHub and Stripe do, and the docs' one-click token makes re-showing unnecessary.
- A test token minted per request in the docs. Invisible to the developer, which is the problem: a credential nobody can see in the console is one nobody can revoke.
Decision
Decided: API tokens as proposed, beside keys. Shown once and never stored. Revocation is enforced by the gateway as calls are counted. The advisor runs on our own model and only proposes.
Publication
The developer docs' authentication page leads with tokens and keeps keys as the
production path; the API reference gains the credential bar; the quickstart's first step
becomes "Create a token". The console's keys page becomes "API tokens and keys". The
accounts service's contract (docs/accounts/README.md) describes the mechanism in full.
Status log
- 2026-10-01: decided and opened with the platform lab. The service side (tokens, revocation through the gateway's counting, the advisor, the console's listing with search and pages) is built first; the console and the docs follow.
- 2026-10-02: live. Measured: a revoked token refused 2.3 seconds after it was revoked. The first live test found that a token used only on the "who am I" route was never reported, so never refused there; every route now checks the token and reports it.
- 2026-10-02: the console and the docs shipped. The console lists tokens and keys with search, filters and pages, and proposes a token from a plain description. The docs make a seven-day test token in one click above every playground, and every call and code sample on the page carries it.
- 2026-10-07: Checked: still live in accounts (932c4445) and protocol (dab02d1a). Status unchanged.