eetr-auth
Features

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_credentialsAPI key
What the caller shipsclient_id + client_secretone opaque key
Revoke one pipelinerotate the secret for everyonerevoke that key
Expirynoneoptional, per key
Token subemptythe bound user
Scopeseverything the client is granteda 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 prefix

The 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_ID

Revocation 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:

ConditionWhy
The token was issued by the client in the pathOtherwise knowing another client's public client_id would be enough to manage its keys
The token carries a subA client_credentials token names no user, so there is nobody to confine the request to
The token was not minted by an API keySee 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 logapi_key.create (with the key id, bound user, and scopes; never the secret) and api_key.revoke.
  • Token activity log — one api_key row per exchange, successful or not.
  • last_used_at on the key itself, stamped on each successful exchange, so an unused key is easy to spot and retire.

On this page