Skip to content

API keys

cdk_ keys, admin-only management routes, scope validation, and runtime authentication.

Intended audience: Stakeholders, Business analysts, Solution architects, Developers, Testers

Learning outcomes by role

Stakeholders

  • Explain API keys as long-lived automation credentials with scope risk.

Business analysts

  • Write stories for issuance, rotation, and revocation of cdk_ keys.

Solution architects

  • Plan storage, hashing, and encryption for keys at rest and in transit.

Developers

  • Send X-API-KEY and interpret ApiKeyTokenSession behavior.

Testers

  • Validate scope enforcement, org binding, and invalid key paths.

API keys are long-lived secrets for machine-to-machine automation — scripts, CI pipelines, and service integrations that cannot go through the browser OAuth flow. The raw key value is shown exactly once at creation; after that only a hash is stored. At runtime, callers send the key in a single header and the platform resolves it to an ApiKeyTokenSession with the same permission model as interactive users.

  • Risk profile — A leaked key provides persistent access until explicitly revoked. Keys bypass interactive MFA; treat them like passwords, scope them to the minimum permissions needed, and rotate on any suspicion of exposure.
  • Lifecycle — Only platform sys-admins create keys. Revocation is immediate once the key row is deleted.
  • Actors — Platform admins (or delegated automation accounts) create keys; integrations authenticate without an interactive user session.
  • Acceptance — Each key carries a scopes list of cadence:* permission strings. Requesting a scope the creating user does not hold is rejected at creation.
API key authentication flow X-API-KEY header is validated in AuthenticationMiddleware, then TenantContextMiddleware builds a synthetic session. HTTP request with X-API-KEY header AuthenticationMiddleware Hash lookup → request.state.api_key_row TenantContextMiddleware ApiKeyTokenSession + memberships; scopes via sanitize_api_key_flat_scopes

See Security and access for JWT versus API key paths and cadence.core.middleware_setup registration order in cadence.main.

  • sys_admin flag on the caller’s session (enforced by require_platform_sys_admin)
  • cadence:system:api_keys:write permission for create and revoke
  • cadence:system:api_keys:read permission for list
FieldPresent atNotes
idAlwaysUUID — use for revoke
nameAlwaysHuman-readable label set at creation
key_prefixAlwaysFirst few chars of the key for identification without exposing the secret
raw_keyCreation response onlyFull cdk_ secret — store immediately, never retrievable again
scopesAlwaysList of cadence:* permission strings the key grants
expires_atAlwaysOptional expiry; null means no expiry
last_used_atAlwaysBest-effort timestamp of last authenticated request
created_atAlwaysCreation timestamp
is_activeAlwaysfalse means the key is soft-revoked and will return 401

All routes live under POST /api/admin/users/api-keys. They require both sys_admin session flag and the matching system permission — there is no self-service /api/me/api-keys surface.

MethodPathPermissionAction
POST/api/admin/users/api-keyscadence:system:api_keys:writeCreate a key; returns raw_key once
GET/api/admin/users/api-keyscadence:system:api_keys:readList active keys for the caller’s user
DELETE/api/admin/users/api-keys/{key_id}cadence:system:api_keys:writeRevoke a key immediately

When creating a key, each requested scope must satisfy two conditions. First, the scope must appear in the global PERMISSIONS set — requesting an unknown permission returns 422. Second, unless the creating user is sys_admin, the user must already hold that permission either globally or in at least one org — you cannot grant a scope you do not yourself possess.

cadence/domain/api_key/service.py
def validate_scopes(self, scopes: List[str], session) -> None:
"""Ensure each scope is known and the caller may grant it."""
for s in scopes:
if s not in PERMISSIONS:
raise ValidationError(f"Unknown scope/permission: {s}")
if session.is_sys_admin:
continue
if session.has_permission(s, None):
continue
org_ok = any(
session.has_permission(s, oid) for oid in session.membership_org_ids
)
if not org_ok:
raise AuthorizationError(f"Cannot grant scope you do not hold: {s}")

At runtime, TenantContextMiddleware further strips any dangerous permissions from the session via sanitize_api_key_flat_scopes — so even if a scope was granted at creation, certain elevated permissions are removed from the effective session.

  1. Send the raw cdk_ secret in the X-API-KEY header on every request. Do not use Authorization: Bearer — that header is for JWTs only (see Security and access).
  2. AuthenticationMiddleware hashes the incoming value and looks up the matching row. A missing or revoked row returns 401.
  3. TenantContextMiddleware builds an ApiKeyTokenSession carrying the user id, org memberships, and the sanitized scope list. Route-level roles_allowed checks run against this session exactly as they would for a JWT session.