Skip to content

API Keys

A key is for a program: a script, a server, an integration calling the API without a browser and without a password. It belongs to an account, carries a scope of its own, and can be revoked on its own. Keys come with the api_keys feature; a project without that feature has no key routes, no X-API-Key transport and no usage tables. See Composable Features.

Creating a key

Minting a key is something an account does while signed in: every /api/v1/api-keys/ route takes a session and refuses a key, so no key can mint another. The account's address must be verified as well, since an unverified one may belong to somebody else; an account that hasn't confirmed its address answers 403 (recovery flows sends the link).

curl -X POST http://localhost:8000/api/v1/api-keys/ \
  -b cookies.txt \
  -H "Content-Type: application/json" \
  -H "X-CSRF-Token: <token-from-login-response>" \
  -d '{
    "name": "Nightly importer",
    "permissions": ["user.read"],
    "usage_limits": {"requests_per_minute": 60, "requests_per_day": 10000},
    "expires_at": "2027-01-01T00:00:00Z"
  }'
{
  "id": 1,
  "api_key": "fai_7Qd3kP0r_… shown once",
  "key_prefix": "7Qd3kP0r",
  "name": "Nightly importer",
  "permissions": ["user.read"],
  "usage_limits": {"requests_per_minute": 60, "requests_per_day": 10000},
  "expires_at": "2027-01-01T00:00:00Z",
  "is_active": true,
  "last_used_at": null
}

The full key is in api_key and appears only in this response — nothing stores it, so a lost key is replaced rather than recovered. key_prefix is the readable handle: it identifies the key in a listing without being a credential.

Field What it does
name yours, for telling keys apart
permissions the key's scope: registry permission names, each one the caller holds (what a key holds)
usage_limits requests per window (usage limits)
expires_at when the key stops working; optional, and can later be brought forward but never pushed back
key_metadata anything your project wants to hang off the key; never returned by the create route. Capped at 16 levels of nesting and 8 KB stored, so a value no read can serialize cannot be written

Calling with a key

curl http://localhost:8000/api/v1/auth/me -H "X-API-Key: fai_7Qd3kP0r_…"
{"user_id": 1, "username": "importer", "email": "…", "is_superuser": false, "scopes": ["user.read"], "via": "apikey"}

The request is authenticated as the key's owner, with transport="apikey" on the principal. No CSRF token is needed — CSRF guards a cookie the browser attaches by itself, and a key is sent deliberately — and the answer carries no cookie, so a key request never becomes a session. When a request carries both a session cookie and a key, the session wins.

A key that is unknown, malformed, revoked, expired, whose row holds a column no schema can read, or whose owner a soft delete has taken out answers 401. So does one of those on a route that otherwise answers anonymous callers: a credential that is present and wrong is not an absent one.

What a key holds

A key-authenticated request holds its owner's permissions, narrowed to the key's scope. The scope only ever narrows: a permission the owner loses disappears from the key at its next request, and a key scoped to nothing holds nothing beyond naming its owner.

curl http://localhost:8000/api/v1/permissions/me -H "X-API-Key: fai_…"
# → {"permissions": ["user.read"]}

A key's scope may name only permissions its creator holds when it is minted, so a key is never stronger than the credential that made it, and a key is never a superuser — the flag reaches ownership checks no scope can express. Permissions has the detail and the reasoning.

What a key cannot do

These routes take a session and nothing else, so a key can neither escalate through them nor erase its own tracks:

Route Why
POST /api/v1/auth/change-password an account changes its own password while signed in
POST /api/v1/auth/email/change-request the same, for the address a recovery flow would use
POST /api/v1/auth/logout, /logout-all a key holds no session to end
GET /api/v1/auth/sessions, DELETE /api/v1/auth/sessions/{handle} a key holds no device to list or sign out
DELETE /api/v1/users/{username} a key must not close the account that issued it
every /api/v1/api-keys/ route a key must not mint, read, rescope or revoke a key, its own included

Ownership is a separate axis from the scope: a key acts on what its owner owns. That is not a licence of its own, though. Editing the owner's own profile takes user.update in the key's scope as well, so a key minted for reading cannot rename the account behind it, and the routes above are refused whatever a key is scoped to. A scope names only what its owner holds, so this takes that owner holding user.update too: the rbac feature is where an ordinary account gets it, from a role carrying it, and with no permission source configured only a superuser's keys hold it (Permissions).

Usage limits

A key may carry a request count per window, any or all of three:

{"requests_per_minute": 60, "requests_per_hour": 1000, "requests_per_day": 10000}

Nothing else is accepted — a name no window counts answers 422, so a limit that would enforce nothing cannot be stored. Each window is counted per key id through the limiter behind the login lockout, so the counts are shared by every worker unless RATE_LIMITER_BACKEND=memory (rate limiting).

A key that fills a window answers 429 with Retry-After. That happens as the key authenticates, so it covers every route the key could reach, including those the API throttle leaves alone. A key with no limits is not counted at all, and counting fails open: a limiter that can't be reached lets the request through rather than taking the API down with it.

A key carrying more than one window is counted against them shortest first, and the first window it fills refuses it. So a refusal costs nothing in the longer windows. Take one minute's worth of 1 and one day's worth of 10: ten requests inside a minute are answered once and refused nine times, and leave nine of the ten for the rest of the day rather than none. The window that refuses is counted either way.

Retry-After names the time until the last window the key is still full in rolls, which can be a longer window than the one that refused. The longer windows are read for it and never counted, so the header does not send a client back on the minute while the day it has spent still refuses it.

A key the transport cannot use answers 401, and with the ratelimit feature present that refusal spends the budget an anonymous caller from the same address spends on that path, so a flood of junk keys is bounded the way anonymous traffic is. A key that does resolve keeps its owner's own budget, which junk from the same address never spends.

A limit stored before this vocabulary existed — anything outside those three names, or a value that isn't a positive whole number — reads back as it is and counts for nothing, the way a stored permission the registry no longer knows grants nothing. uv run --no-sync python scripts/cleanup_api_key_json.py from backend/, or docker compose exec api python -m scripts.cleanup_api_key_json in the container, rewrites those rows: it keeps the windows the limiter counts and drops the rest, so what remains is exactly what gets enforced. It logs what each key lost, so a limit that was meant — {"requests_per_day": "1000"}, say, which was never enforced because the count is a string — can be set again as a number. A key whose row holds one of those shapes answers 401 at authentication, with a warning naming the key, rather than answering 500 on every request it makes; the owner's listing still answers 500 for that row until the repair runs.

What a key's usage records

Every request a key authenticates writes one row, and so does the first refusal each of its windows answers:

curl http://localhost:8000/api/v1/api-keys/1/usage -b cookies.txt
curl http://localhost:8000/api/v1/api-keys/1/analytics -b cookies.txt
curl http://localhost:8000/api/v1/api-keys/summary/user -b cookies.txt

The row carries the path, the method, the status the client actually got, how long the request took, the address and the user agent. Refusals are recorded too, so an owner can see a key being throttled or failing: every 500 from a route that raised, and the first 429 each window answers from the key's own limits. The refusals after that one are answered and counted but not written, so a client holding a key at its limit costs the limiter alone rather than a row and a stamp for every request it makes. A session request records nothing.

A request a route refuses before the key is authenticated is the exception: the session-only routes try the session transport alone, so nothing there resolves the key, and whether such a request leaves a row depends on what else the route resolves first — with the ratelimit feature present, its per-caller throttle resolves every transport and the row appears. The answer the client gets (401) is the same either way.

The write runs as a background task after the response, so no client waits for it, and the same task stamps last_used_at and last_used_ip on the key. Authenticating a request therefore writes nothing itself. "Last used" trails by however long that task takes, and a process that dies before it runs doesn't record that request at all.

The stamp and the row are two writes taken one at a time, so one of them failing leaves the other written, with the failure logged. last_used_at is when the request arrived, not when its write landed, and a key already stamped later than that keeps the stamp it has. So of two requests whose writes land out of order, the key names the later request, time and address together.

Both the row and the key record the address the trusted-proxy boundary resolved (TRUSTED_PROXY_HOPS), not the socket's, so a deployment behind a proxy records its clients rather than the proxy. A forwarding header is the client's to write, so the address is recorded only when it is one the column holds: anything else, and a request with no address at all, is recorded as nothing. A row the usage schema refuses is logged and dropped, so a request keeps the status its handler gave it. The user agent is a header, so it is the client's to write as well: the row keeps the first 512 characters of it, which is what the owner reads back.

How long the usage rows are kept

KEY_USAGE_RETENTION_DAYS (default 90) says how many days of rows to keep. Each row carries the caller's address and user agent, one row per request, so the table holds personal data at request volume. 0 keeps every row and prunes nothing, and a value below 0 is refused as the settings load.

The rate limiter counts in its own store and reads none of these rows, so pruning changes nothing about what a key is allowed. The analytics routes read no further back than what is kept (days is capped at the retention, and at 365 where nothing is pruned), and total_requests and total_cost_microcents in the user summary are totals over that window rather than over the key's life. A project that bills from these rows has to aggregate them somewhere of its own before they are pruned; the boilerplate doesn't bill. A key's own last_used_at and last_used_ip are stamped on the key, not read from these rows, so they survive every prune.

With the taskiq feature the prune is a scheduled task, api_keys:prune_usage, which the scheduler enqueues at 03:00 UTC:

cd backend
uv run --no-sync taskiq scheduler src.infrastructure.taskiq.scheduler:scheduler

Nothing is pruned unless that scheduler runs next to a worker. Without the taskiq feature, the same prune is a script for a system cron:

cd backend
python scripts/prune_key_usage.py

Either way it deletes 10,000 rows per statement, committing each batch, and logs how many rows it deleted in all. With a retention of 0 the task returns without reading a row and the script says nothing is configured to prune.

Revoking, expiring and rotating

# Narrow a key, or bring its expiry forward
curl -X PATCH http://localhost:8000/api/v1/api-keys/1 \
  -b cookies.txt -H "Content-Type: application/json" -H "X-CSRF-Token: <token>" \
  -d '{"permissions": [], "expires_at": "2026-11-01T00:00:00Z"}'

# Revoke it
curl -X DELETE http://localhost:8000/api/v1/api-keys/1 -b cookies.txt -H "X-CSRF-Token: <token>"

Revoking is one way: a revoked key cannot be reactivated, and an expiry can be brought forward but never pushed back, so neither route can be used to widen a key quietly. Rotate by creating the new key, moving the client onto it, then revoking the old one — both work at once, and the usage rows show when the old one stops being called.

Recovering an account revokes its keys. Each key records the owner's credential generation (owner_token_version) as it is minted, and a key from an older generation answers 401. Three things move that generation on, all of them someone taking the account back: changing the password, completing a password reset, and claiming the account through an OAuth provider. So a key minted from a stolen session dies when the owner resets their password, and a key minted under an address somebody else later proves they own dies when they claim it.

Ending sessions is not one of those: POST /auth/logout-all signs every device out and leaves the keys working, because a key holds no session. Revoke a key you believe has leaked.

How a key is stored

A key is secrets.token_urlsafe(48) — 48 random bytes, 384 bits, generated by the server — stored as sha256$<digest> of itself. The digest is deterministic, so a request finds its row through one indexed lookup on key_hash.

Why not a slow hash

A work factor (bcrypt, scrypt, Argon2) exists to make guessing expensive, which matters for a password a human chose. There is nothing to guess here: a key is server-generated randomness, so a slow hash buys no security — and it costs. An earlier version used scrypt, which made every request pay a derivation and, because key_prefix appears in listings, let anybody holding a prefix make the server pay it on junk requests. A code scanner may flag SHA-256 on something it reads as a password; for this column that reading is wrong. Account passwords in this project are hashed by crudauth with bcrypt and are not involved.

A key stored by an older version under a salted scrypt hash still authenticates, found by prefix and verified the old way, and its row is rewritten as a digest the first time it is used. That rewrite is a write of its own rather than part of the request's transaction, so a request that fails afterwards leaves it done, and a rewrite that fails leaves the key valid with the next use trying it again. Until then, every request carrying that key's prefix costs a derivation, not just the ones its owner makes: the prefix is in the owner's listing, so whoever holds it can ask for that work as often as they like. Only a row a request could authenticate with is verified against, so a key that is revoked or has expired costs nothing, and a key that gets used costs nothing from then on.

uv run --no-sync python scripts/revoke_scrypt_api_keys.py from backend/, or docker compose exec api python -m scripts.revoke_scrypt_api_keys in the container, names the keys still stored that way with the prefix and the name their owners see, so the owners can be told to mint new ones. Run with --revoke it deactivates them, which ends the cost without waiting for a key to be used, and leaves every key stored as a digest alone.

X-API-Key in the interactive docs

/docs has no field for the key: the header is not declared as an OpenAPI security scheme. A global declaration would claim every route accepts a key, which isn't true — the routes above refuse one — and per-route declarations would have to be kept in step with those refusals by hand. To add one anyway, give the routes that should advertise it a dependency of their own:

from fastapi import Security
from fastapi.security import APIKeyHeader

api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)


@router.get("/widgets/", dependencies=[Security(api_key_header)])
async def list_widgets(...): ...

The transport authenticates the request either way; the scheme only tells the document, and Swagger, that the header is expected.

Key Files

Component Location
Transport (X-API-Key) backend/src/modules/api_keys/transport.py
Service (minting, validation, usage) backend/src/modules/api_keys/service.py
Usage recorder (middleware) backend/src/modules/api_keys/usage.py
Limit windows backend/src/modules/api_keys/limits.py
Retention and pruning backend/src/modules/api_keys/retention.py
Scheduled prune (with taskiq) backend/src/modules/api_keys/tasks.py
Prune script (without taskiq) backend/scripts/prune_key_usage.py
Script for the keys still on scrypt backend/scripts/revoke_scrypt_api_keys.py
Routes backend/src/modules/api_keys/routes.py
Models (APIKey, KeyUsage) backend/src/modules/api_keys/models.py
Transport registration backend/src/wiring/transports.py