API keys
Long-lived per-client credentials, bound to a user, that CI/CD exchanges for a short-lived access token.
Why
A CI pipeline that needs a token today has to use the client_credentials grant: every job
carries the client's client_id and client_secret, and makes a token call before it can
make an API call. That has three problems.
- The client secret is the client's credential. Handing it to a pipeline means every pipeline holding it can do everything the client can, and taking access away from one of them means rotating the secret for all of them.
- There is nothing to expire. A client secret lives until someone rotates it.
- The resulting token has no subject, so nothing in the audit trail says which person stood behind the machine.
An API key fixes all three. It is a long-lived credential issued for a client, bound to a user, individually revocable, optionally expiring, and optionally narrower than the client itself.
client_credentials | API key | |
|---|---|---|
| What the caller ships | client_id + client_secret | one opaque key |
| Revoke one pipeline | rotate the secret for everyone | revoke that key |
| Expiry | none | optional, per key |
Token sub | empty | the bound user |
| Scopes | everything the client is granted | a subset, chosen per key |
The credential
A key is presented as three underscore-separated parts:
eak_3f2a9c1b4d5e6f70_a1b2c3…
│ │ │
│ │ └─ secret — 32 random bytes, hashed at rest, shown once
│ └─ key id — 8 random bytes, stored in the clear
└─ fixed prefixThe middle segment exists because an Argon2id digest is not searchable: unlike a client secret,
which arrives next to its client_id, an API key has to carry its own lookup handle. That
handle is safe to display, log, and use to address the key in the admin API — only the secret
half is a credential.
The secret is shown exactly once
It is displayed in the dashboard immediately after creation and returned once from the create endpoint. There is no way to recover it afterwards — issue a new key and revoke the old one.
Keys are hashed with the same argon-hasher Worker and the same
HASH_METHOD policy as user passwords, so production is always Argon2id and
local development works without the Rust Worker running.
Binding to a user
Every key names a user, and that user's id becomes the sub of every token the key mints. The
binding is mandatory: a machine token that nobody is accountable for is exactly what this feature
exists to avoid.
Two consequences follow:
- Deleting the user deletes their keys — a departed employee's pipelines stop, rather than quietly continuing to mint tokens in their name.
- A test user may only be bound to a test client, the same confinement that applies to interactive sign-in.
Scopes are a snapshot
A key may hold any subset of the scopes its client is granted; selecting none at creation means "all of them, as of now".
That subset is frozen at issue time. A key does not pick up scopes granted to the client later, so widening a client never silently widens the keys already in the wild. Conversely, when a scope is ungranted from the client it disappears from every key that held it — and a key whose scopes have all been ungranted mints nothing at all, rather than falling back to whatever the client happens to hold now.
A request may narrow further at exchange time, but never widen: asking for a scope the key does
not hold is an invalid_scope error, not a grant.
Managing keys
From the dashboard: Clients → (select a client) → API keys. Create one by choosing the user, an optional name and expiry, and the scopes it may mint. Revoke from the same list.
Or through the Admin API:
# Issue a key for a client, bound to a user, narrowed to one scope
curl -X POST https://auth.example.com/api/admin/clients/$CLIENT_ID/api-keys \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"userId":"ci-bot","name":"deploy pipeline","expiresAt":"2027-01-01T00:00:00Z","scopes":["api"]}'# List (never returns secrets) and revoke
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
https://auth.example.com/api/admin/clients/$CLIENT_ID/api-keys
curl -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" \
https://auth.example.com/api/admin/clients/$CLIENT_ID/api-keys/$KEY_IDRevocation is a soft delete: the record survives so the audit trail still resolves, and revoking twice keeps the original timestamp. It takes effect on the next exchange — access tokens already minted live out their remaining hour.
Self-service: a user managing their own keys
The admin routes above need an admin API client — a credential an operator holds. That is the right shape for provisioning a pipeline, and the wrong shape for letting a signed-in developer mint a key for themselves.
So the same three routes accept a second kind of caller: a user-scoped JWT issued by the very client named in the path. No configuration is needed; a client can always manage its own keys.
# $USER_TOKEN is an ordinary access token this client minted for a signed-in user
curl -X POST https://auth.example.com/api/admin/clients/$CLIENT_ID/api-keys \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"my laptop","scopes":["api"]}'There is no userId: the token already says who the user is, and that is the only user this
caller may bind a key to. The same confinement applies throughout — the list returns only
that user's keys, and revoking someone else's answers 404, never 403, so the handle's
existence is never confirmed.
A caller qualifies only if all three hold:
| Condition | Why |
|---|---|
| The token was issued by the client in the path | Otherwise knowing another client's public client_id would be enough to manage its keys |
The token carries a sub | A client_credentials token names no user, so there is nobody to confine the request to |
| The token was not minted by an API key | See below |
A key cannot issue its own successor
Without the third rule, a key expiring next week and narrowed to read could exchange
itself for a token and use that token to create a never-expiring key holding every scope
the client has — laundering away both limits it was created with. Tokens from
/api/token/api-key therefore carry their originating key (tokens.api_key_id), and these
routes refuse them. Admin callers are unaffected.
Failing the first condition returns exactly the message a plain non-admin token gets, so these endpoints cannot be probed for which clients are configured as admin API clients.
The user is always told
A self-service key is authorized by the user's own token with no administrator in the loop, so the bound user is emailed whenever one is created — naming the client, the key id, its scopes and expiry, but never the secret. An unexpected message is how someone learns their access token has been taken.
That makes an email address a precondition, not a nicety:
- A user with no email address on file is refused with
400, before anything is written. - If the message cannot be delivered, the key is revoked and the call fails. A key nobody could be told about does not stay usable.
Neither applies to the admin path: an operator issuing a key for a service account is already the accountable party, and that account often has no mailbox at all.
Exchanging a key for a token
curl -X POST https://auth.example.com/api/token/api-key \
-H "Authorization: Bearer $EETR_API_KEY"{
"token_type": "Bearer",
"access_token": "eyJhbGciOiJSUzI1NiIs…",
"expires_in": 3600,
"scope": "api"
}The key may also be sent as an api_key field in a form-encoded or JSON body; the
Authorization header wins if both are present. Optional scope narrows the token, and
resource sets the audience (RFC 8707).
There is deliberately no refresh_token. The API key is already the long-lived credential —
a second one would widen the blast radius without shortening any path.
This endpoint is not an OAuth grant and does not live on /api/token: it authenticates with a
single opaque credential rather than a client id and secret, so keeping it separate leaves the
token endpoint spec-clean.
Every failure looks the same
Malformed, unknown, revoked, expired, and wrong-secret all return the same
401 invalid_client. The endpoint is unauthenticated and an attacker controls the whole input,
so a more specific message would turn it into an oracle for enumerating valid key ids. The real
reason is in the activity log, under request type API key.
From the client library
import { exchangeApiKey } from "@eetr/eetr-auth-client";
const token = await exchangeApiKey(
{ apiKey: process.env.EETR_API_KEY! },
{ apiKeyEndpoint: "https://auth.example.com/api/token/api-key" }
);listClientApiKeys, createClientApiKey, and revokeClientApiKey cover the management side.
See the client library.
What gets recorded
- Admin audit log —
api_key.create(with the key id, bound user, and scopes; never the secret) andapi_key.revoke. - Token activity log — one
api_keyrow per exchange, successful or not. last_used_aton the key itself, stamped on each successful exchange, so an unused key is easy to spot and retire.