API keys, RBAC scopes, and key management for the 9thSense platform

Authentication


Every request to the 9thSense API must carry an API key in the X-Api-Key header. The platform uses a split-key scheme that enables fast database lookups without storing secrets in plaintext.

Key format

All new keys follow the pattern zk_<env>_<prefix>_<secret>:

  • zk — fixed sentinel identifying a 9thSense key.
  • <env> — the environment the key is bound to (e.g. live, test). Keys created before environments existed omit it: zk_<prefix>_<secret>.
  • <prefix> — 12 hex characters (6 random bytes). Stored in plaintext in the database and used for an indexed lookup.
  • <secret> — 32 hex characters (16 random bytes). Never stored. The full key is bcrypt-hashed and the hash is stored.

On each request, the middleware extracts the prefix, fetches the matching row by prefix, then calls bcrypt.checkpw(presented_key, stored_hash) to verify. Legacy keys (SHA-256 hash stored without a prefix) are still accepted for backward compatibility.

The raw key is returned once at creation time and cannot be retrieved again. Store it immediately.

Sending the key

curl https://api.9thsense.ai/v1/cases \
  -H "X-Api-Key: zk_live_a1b2c3d4e5f6_0123456789abcdef0123456789abcdef"
import httpx

client = httpx.Client(
    base_url="https://api.9thsense.ai",
    headers={"X-Api-Key": "zk_live_a1b2c3d4e5f6_0123456789abcdef0123456789abcdef"},
)

resp = client.get("/v1/cases")
resp.raise_for_status()

The middleware returns 401 Missing X-Api-Key header if the header is absent, and 401 API key invalid if the key is unknown or revoked. Expired keys get 401 API key expired; a key awaiting approval gets 401 API key pending approval, and a rejected one 401 API key rejected.

RBAC scopes

Every API key carries a list of scopes from a 19-scope catalog (tenant:read, case:write, document:write, tool:invoke, …; see the scope table). Endpoints enforce the required scope via the require_scope() dependency. If your key lacks it the API returns 403 Insufficient permissions: requires '<scope>' scope.

Request exactly what the service needs. A backend that creates cases and uploads documents needs ["case:read", "case:write", "document:write"]; a reporting job needs only read scopes such as ["case:read", "report:read"]. A :write scope does not include the matching :read. The legacy read, write and admin names are rejected with 422.

Org admins' keys work immediately. When a tenant member requests a privileged scope (any :write, :delete, agent:run, tool:invoke or tenant:admin) the key is created pending_approval and returns 401 API key pending approval until an org admin approves it.

Creating a key

Key management uses a dashboard session (Authorization: Bearer <jwt>), not an API key: an API key calling these routes gets 403. Org admins create org-owned keys with POST /v1/admin/keys; members (and admins, for personal keys) use POST /v1/keys.

curl -X POST https://api.9thsense.ai/v1/admin/keys \
  -H "Authorization: Bearer $DASHBOARD_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "production-backend",
    "environment": "live",
    "scopes": ["case:read", "case:write", "document:write"],
    "expires_in_days": 90
  }'

Response — 201 Created:

{
  "key": {
    "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "name": "production-backend",
    "prefix": "a1b2c3d4e5f6",
    "environment": "live",
    "scopes": ["case:read", "case:write", "document:write"],
    "status": "active",
    "owner": null,
    "created_at": "2026-03-25T09:00:00Z",
    "expires_at": "2026-06-23T09:00:00Z",
    "last_used_at": null,
    "revoked_at": null,
    "purge_at": null,
    "approval": null
  },
  "secret": "zk_live_a1b2c3d4e5f6_0123456789abcdef0123456789abcdef"
}

secret is the only time the key is visible. Store it in your secrets manager immediately.

Listing keys

curl "https://api.9thsense.ai/v1/admin/keys?status=active" \
  -H "Authorization: Bearer $DASHBOARD_JWT"

The response is an array of key resources (the key object above) and never includes the secret. last_used_at is updated on every authenticated request (fire-and-forget, never blocks the response).

Revoking a key

curl -X POST https://api.9thsense.ai/v1/admin/keys/3fa85f64-5717-4562-b3fc-2c963f66afa6/revoke \
  -H "Authorization: Bearer $DASHBOARD_JWT"

Returns 200 with the key ("status": "revoked"). Revoked keys are rejected immediately on the next request. To remove a revoked, expired or rejected key permanently, call DELETE /v1/admin/keys/{key_id} (204).

Open paths

The following paths never require authentication: /healthz, /docs, /openapi.json, /redoc, /metrics. All other paths enforce key validation when auth is enabled.