Reference

Authentication and scopes

Scoped API keys, scrypt hashing, project binding, and the bootstrap flow that keeps Cradle secure by default.

Cradle uses scoped API keys instead of a single shared secret. Every HTTP request resolves an AuthContext through scrypt verification and a per-route scope check.

Key storage

CREATE TABLE api_keys (
  id           TEXT PRIMARY KEY,
  name         TEXT NOT NULL,
  key_hash     TEXT NOT NULL UNIQUE,
  key_prefix   TEXT NOT NULL,
  project_id   TEXT REFERENCES projects(id) ON DELETE CASCADE,
  scopes       TEXT NOT NULL DEFAULT '[]',
  created_at   INTEGER NOT NULL,
  last_used_at INTEGER,
  expires_at   INTEGER,
  revoked_at   INTEGER
);

CREATE INDEX api_keys_key_prefix_idx ON api_keys (key_prefix);

api_key_usage logs every request (endpoint, status, IP, timestamp) with a 30-day retention job.

Key format and hashing

  • Plaintext: ck_live_<64 hex> (~256 bits of entropy).
  • Prefix: first 12 characters, indexed for lookup.
  • Hash: scrypt$<salt-hex>$<derived-hex> using Node's crypto.scrypt.

The plaintext is shown once at creation and never persisted again. Lookup is prefix → candidate rows → scrypt-verify until a match.

Scope grammar

Scopes follow <namespace>:<action>:

messages:read     messages:write
agents:read       agents:write       agents:execute
kb:read           kb:write
models:read       models:write
tickets:read      tickets:approve
admin:*           — full access

Matching supports namespace wildcards:

  • matchesScope(['agents:*'], 'agents:write') → true.
  • matchesScope(['admin:*'], <anything>) → true.
  • matchesAllScopes(granted, required) checks every required scope.

Middleware flow

requireAuth(req, reply, requiredScopes):

  1. Extract the key from Authorization: Bearer <key> or X-Api-Key: <key>.
  2. SELECT * FROM api_keys WHERE key_prefix = ?.
  3. Skip revoked or expired keys; scrypt-verify candidates.
  4. No match → 401 unauthorized.
  5. matchesAllScopes(granted, requiredScopes)403 forbidden with { missing: [...] }.
  6. Resolve the project via the X-Cradle-Project header or the key's binding.
  7. Bump last_used_at, record usage, return AuthContext.

Example endpoint declaration:

server.get('/api/v1/admin/agents', async (req, reply) => {
  const auth = await requireAuth(req, reply, ['agents:read'])
  if (!auth) return
  return getDb().select().from(agents).where(eq(agents.projectId, auth.projectId)).all()
})

Project switching

Admin keys (admin:*) may use X-Cradle-Project: <slug> to act on a different project per request. Scoped keys can only use the project they are bound to; anything else returns 403 project-mismatch. Unknown slugs return 404 project-not-found; archived projects return 410 project-archived.

Bootstrap

On every boot, ensureBootstrapAdminKey() runs:

  • If api_keys is empty and a legacy channels_config.api.config.apiKey exists, it is hashed and inserted as an admin:* key.
  • Otherwise a fresh ck_live_… key is generated, written back to channel config, and inserted. The plaintext is logged once so the operator can copy it.

This makes headless installs usable immediately without manual key creation.

Why scrypt and not argon2

API keys are high-entropy random strings, not user passwords. Slow KDFs mainly defend low-entropy brute-force, and scrypt from Node's stdlib has no extra native dependency. Replacing it with argon2 is a one-file change in src/core/auth/keys.ts if a future audit requires it.