Skip to main content

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.

ColumnNotes
tenant_id, nameNames are unique per tenant among active keys (case-insensitive).
environmentlive or test; must match the key text.
statusStored active/revoked; expired is derived from expires_at.
scopesPermission strings, validated against the catalogue.
expires_at, last_used_at, last_used_iplast_used_* is written at most once a minute per key.
revoked_at, revoked_reason, revoked_by, created_by, rotated_from_idLifecycle 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_tenant calls enforce_api_key_tenant: an X-Tenant-ID other than the key's tenant is 403. No membership row is needed or consulted.
  • No identity endpoints. /me, /users, /auth, /invites, /api-keys, /platform, /tenants and /roles answer 403 for keys; the api_keys:, users:, auth:, me:, invites:, mfa:, sessions: scopes can never be assigned.
  • Scopes decide. A key has no roles, so claims_from_roles never applies; the central gate (user_has_permission) and principal_has_scope(user, permission) (app/api/deps.py) check the key's scopes instead: users pass iff a role grants the permission, keys iff the permission is in scopes. As a fail-closed backstop for routes with no explicit gate, get_current_user additionally 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 pathPermissionNotes
GET /api-keys/scopesapi_keys:readAssignable scopes.
POST /api-keysapi_keys:manage{name, environment, scopes, expires_at?} -> 201 with the plain key once.
GET /api-keys, GET /api-keys/{id}api_keys:readNever returns hashes. Filter status, environment; paginated.
POST /api-keys/{id}/rotateapi_keys:manageNew key inherits name, environment, scopes.
DELETE /api-keys/{id}api_keys:manageOptional {reason}; immediate.
GET /platform/api-keys, DELETE /platform/api-keys/{id}superadminAll 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).