API keys and machine identities
An API key lets a script or integration call core-api without a person's login. It is bound to
one tenant, carries an explicit list of scopes (permission strings) and authenticates as
a role-less system principal. See also Identity and authentication.
Key format and storage
bss_<live|test>_<32 base62 characters>, derived from 32 random bytes. The plain key is shown
once (on create and rotate); only its SHA-256 is stored (api_keys.key_hash, unique).
prefix holds the first 8 characters of the random part so a key can be recognised in lists
(bss_live_AbCd1234...). Verification is one indexed lookup on the hash plus a constant-time
compare, so a request does no heavy work.
| Column | Notes |
|---|---|
tenant_id, name | Names are unique per tenant among active keys (case-insensitive). |
environment | live or test; must match the key text. |
status | Stored active/revoked; expired is derived from expires_at. |
scopes | Permission strings, validated against the catalogue. |
expires_at, last_used_at, last_used_ip | last_used_* is written at most once a minute per key. |
revoked_at, revoked_reason, revoked_by, created_by, rotated_from_id | Lifecycle and provenance. |
The principal
Authorization: Bearer bss_... makes get_current_user return:
{"sub": "apikey:<key id>", "email": null, "roles": ["system"],
"scopes": ["subscribers:read"], "tenant_id": "<tenant uuid>", "auth_type": "api_key"}
Any failure (unknown, revoked, expired, tampered environment) is one generic 401 Invalid API key. Bearer requests are not subject to the cookie CSRF check.
Rules for machine principals:
- Tenant binding.
get_current_tenantcallsenforce_api_key_tenant: anX-Tenant-IDother than the key's tenant is403. No membership row is needed or consulted. - No identity endpoints.
/me,/users,/auth,/invites,/api-keys,/platform,/tenantsand/rolesanswer403for keys; theapi_keys:,users:,auth:,me:,invites:,mfa:,sessions:scopes can never be assigned. - Scopes decide. A key has no roles, so
claims_from_rolesnever applies; the central gate (user_has_permission) andprincipal_has_scope(user, permission)(app/api/deps.py) check the key'sscopesinstead: users pass iff a role grants the permission, keys iff the permission is inscopes. As a fail-closed backstop for routes with no explicit gate,get_current_useradditionally requires a scope whose module matches the first path segment (import-runs->import_runs:*) with a read-like action for GET/HEAD/OPTIONS and a non-read action otherwise. - No privilege laundering. A creator can only grant scopes they hold themselves; rotation re-checks the rotator.
Endpoints
| Method and path | Permission | Notes |
|---|---|---|
GET /api-keys/scopes | api_keys:read | Assignable scopes. |
POST /api-keys | api_keys:manage | {name, environment, scopes, expires_at?} -> 201 with the plain key once. |
GET /api-keys, GET /api-keys/{id} | api_keys:read | Never returns hashes. Filter status, environment; paginated. |
POST /api-keys/{id}/rotate | api_keys:manage | New key inherits name, environment, scopes. |
DELETE /api-keys/{id} | api_keys:manage | Optional {reason}; immediate. |
GET /platform/api-keys, DELETE /platform/api-keys/{id} | superadmin | All tenants (?tenant_id=), revoke any. |
Limits: at most API_KEYS_MAX_ACTIVE_PER_TENANT (default 50) active keys per tenant (409).
Rotation
Rotating issues a new key (rotated_from_id set) and pulls the old key's expires_at in to
now + API_KEY_ROTATION_GRACE_DAYS (default 7, never later than its own expiry). The old key is
reported with superseded: true and can no longer be rotated or counted against the cap.
Audit
Domain api_key, tags api_key, security: create, rotate, revoke (actor: the user) and
first_use (actor apikey:<id>). Entity changes made by a key are attributed to
actor_id = apikey:<id> (actor_from_user reads sub).