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'scrypto.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 accessMatching 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):
- Extract the key from
Authorization: Bearer <key>orX-Api-Key: <key>. SELECT * FROM api_keys WHERE key_prefix = ?.- Skip revoked or expired keys; scrypt-verify candidates.
- No match → 401 unauthorized.
matchesAllScopes(granted, requiredScopes)→ 403 forbidden with{ missing: [...] }.- Resolve the project via the
X-Cradle-Projectheader or the key's binding. - Bump
last_used_at, record usage, returnAuthContext.
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_keysis empty and a legacychannels_config.api.config.apiKeyexists, it is hashed and inserted as anadmin:*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.