API Keys
API key generation, validation, scope enforcement, and RBAC permission resolution for external system integration.
API Keys
API Keys enable external systems, AI agents, and scripts to authenticate with the platform without a user session. Each key is scoped to a tenant, tied to its creating user for permission resolution, and restricted to specific API scopes. Effective permissions are always bounded by what the creating user is allowed to do — a key can never escalate beyond the creator's role.
Overview
The module lives in features/api-keys/ and provides key generation (SHA-256 hashed, only shown once), validation middleware, scope enforcement, permission resolution, and an admin UI for managing keys. API keys are stored in the api_keys table with hashed secrets — the plaintext key is only returned at creation time.
External consumers include Claude Code, OpenClaw agents, MCP tool servers, CI/CD pipelines, and any HTTP client that needs programmatic access to the platform's API routes.
Key Concepts
API Key Format -- Keys use the prefix sk_ followed by 32 random bytes encoded as base64url. The first 12 characters are stored as key_prefix for identification in the UI. The full key is hashed with SHA-256 and stored as key_hash.
ApiScope -- Granular permission scopes that restrict what an API key can do:
| Scope | Description |
|---|---|
entities:read | List and fetch tenant records |
entities:write | Create, update, or delete records |
entity-types:read | List and fetch entity type schemas |
entity-types:write | Create or update entity type schemas |
extraction:trigger | Start extraction workflows |
extraction:submit | Submit extracted field values |
extraction:review | Approve or reject extraction results |
documents:read | List and fetch documents |
documents:write | Upload, update, or delete documents |
relations:read | Fetch entity relations |
relations:write | Create or delete entity relations |
skills:read | List and fetch skills |
views:read | List and fetch views |
tools:execute | Run tool endpoints |
chat:create | Start chat sessions |
* | Full access — all of the creating user's permissions |
Default scope for new keys: extraction:submit.
RBAC via creating user -- Effective permissions are computed as the intersection of:
- The permissions granted by the creating user's role in the tenant
- The permissions implied by the key's assigned scopes
This means an owner-created key with entities:write scope gets owner-level entity write permissions. A guest-created key with entities:write scope gets only the entity write permissions that guest members hold (potentially none). The key never exceeds the creator's ceiling.
The * scope bypasses scope filtering entirely — the key carries all permissions the creating user holds. This is the appropriate scope for trusted internal automation.
ApiKeyAuthContext -- The context object returned when a key is validated:
interface ApiKeyAuthContext {
tenantId: string;
scopes: ApiScope[];
/** UUID of the user who created this key (permission ceiling + activity attribution). */
createdBy: string | null;
/** API key record ID (for audit logging). */
keyId: string;
}How It Works
Key Generation
The createApiKey() server action:
- Generates 32 random bytes, encodes as
sk_+ base64url - Extracts the first 12 chars as
key_prefix - Hashes the full key with SHA-256
- Stores the hash, prefix, scopes, and
created_by(the current user's ID) inapi_keys - Returns
ApiKeyWithSecret(view + plaintext secret, shown only once)
Request Authentication
The authenticateApiKey() middleware function in features/api-keys/lib/middleware.ts extracts an API key from either:
Authorization: Bearer sk_...headerX-API-Key: sk_...header
It calls validateApiKey(), which:
- Hashes the provided key
- Looks up the hash in
api_keys(using admin client for cross-tenant access) - Checks the key is not revoked and not expired
- Updates
last_used_at(fire-and-forget) - Returns
ApiKeyAuthContext(includingcreatedBy) ornull
Permission Resolution
After authentication, API routes call resolveApiKeyPermissions() from features/api-keys/lib/permissions.ts to get the effective permission set:
const permissions = await resolveApiKeyPermissions(apiKeyCtx, membership);
// permissions = intersection of user role permissions AND scope-derived permissionsThe function:
- Looks up the creating user's
roleIdvialookupUserMembership(). Falls back toROLE_IDS.memberifcreatedByis null (keys created before the schema change). - Fetches the role's permission set via
getPermissionsForRole(roleId). - Expands the key's scopes to a permission set via
expandScopePermissions(scopes). - Returns the intersection (both conditions must be satisfied).
- If scopes include
*, skips step 3–4 and returns the full role permission set.
The resolved permissions are passed to resolveAgentTools(config, permissions) so MCP tools and entity tools respect the same RBAC gate as human sessions.
Scope-to-Permission Mapping
SCOPE_TO_PERMISSIONS in features/api-keys/lib/scopes.ts defines what each scope grants:
| Scope | Permissions granted |
|---|---|
entities:read | entities.own.read, entities.team.read, entities.all.read |
entities:write | All entity create, update, delete at own, team, all levels |
extraction:submit | entities.team.read, entities.team.update, responses.team.create |
tools:execute | Full read/write entity permissions, entity types read, responses create, documents read, agents read, views read |
* | Bypasses the map — returns all creating-user permissions unchanged |
Activity Attribution
buildTenantContextForApiKey() constructs the tenant context for API key requests. Activity records (entity creates, updates, deletes) are attributed to the createdBy user with their actual tenant role. If the key has no createdBy, a synthetic anonymous identity is used with member role.
Scope Enforcement
API route handlers check scopes using hasScope(grantedScopes, requiredScope) from features/api-keys/lib/scopes.ts. The wildcard scope * grants access to every named scope. The middleware module provides unauthorizedResponse() and forbiddenScopeResponse() helpers for consistent error formatting.
Key Revocation
revokeApiKey(id) sets revoked_at on the key record. Revoked keys fail validation immediately. There is no un-revoke — create a new key instead.
API Reference
Server Actions
| Function | Location | Purpose |
|---|---|---|
listApiKeys() | features/api-keys/server/actions.ts | List active (non-revoked) keys for the tenant |
createApiKey(input) | Same | Generate and store a new key, storing created_by |
revokeApiKey(id) | Same | Soft-delete by setting revoked_at |
validateApiKey(key) | Same | Hash, look up, check expiry, return ApiKeyAuthContext |
Permission Resolution
| Function | Location | Purpose |
|---|---|---|
resolveApiKeyPermissions(ctx, membership?) | features/api-keys/lib/permissions.ts | Compute effective permissions for an authenticated key |
lookupUserMembership(userId, tenantId) | Same | Fetch creating user's roleId and role slug |
Middleware
| Function | Location | Purpose |
|---|---|---|
authenticateApiKey(request) | features/api-keys/lib/middleware.ts | Extract and validate key from request headers |
unauthorizedResponse(message?) | Same | 401 JSON response |
forbiddenScopeResponse(scope) | Same | 403 JSON response with required scope |
Scope Utilities
| Function | Location | Purpose |
|---|---|---|
normalizeApiScopes(scopes?) | features/api-keys/lib/scopes.ts | Default to extraction:submit, collapse wildcards |
hasScope(granted, required) | Same | Check if granted scopes include the required one |
expandScopePermissions(scopes) | Same | Expand scopes to Set<string> of permissions; returns null for * |
Types
| Type | Location | Purpose |
|---|---|---|
ApiKey | features/api-keys/types.ts | Full key record (includes hash) |
ApiKeyView | Same | Key without hash (for API responses) |
ApiKeyWithSecret | Same | View + plaintext secret (creation only) |
ApiKeyAuthContext | Same | Validated key context (tenantId, scopes, createdBy, keyId) |
CreateApiKeyInput | Same | Creation input shape |
ApiScope | features/api-keys/lib/scopes.ts | Union type of all scope values |
API_SCOPES | Same | Const array of all scope values |
API_SCOPE_OPTIONS | Same | Labeled scope options for UI |
SCOPE_TO_PERMISSIONS | Same | Map from scope to granular app permissions |
UserMembership | features/api-keys/lib/permissions.ts | Pre-fetched membership data (roleId, roleSlug) |
Components
| Component | Location | Purpose |
|---|---|---|
ApiKeyAdmin | features/api-keys/components/api-key-admin.tsx | Full admin CRUD UI with scope selection |
For Agents
Agents do not manage API keys directly. However, API keys are the primary mechanism for external agents (including MCP tool servers) to interact with the platform:
- External AI agents submit extraction results via
POST /api/extraction/[entityId]/submitusing a key withextraction:submitscope - CI/CD pipelines trigger extractions via keys with
extraction:triggerscope - Custom integrations read and write records via keys with
entities:read/entities:writescopes - MCP tool servers (e.g., Claude Code with the Amble MCP) authenticate with
tools:executescope or*
The key's effective permissions are bounded by the creating user's role. An owner should create keys intended for write operations; a guest-created key with entities:write scope will receive only the permissions guest members hold.
Design Decisions
Permission ceiling = creating user's role, not a configurable role. Earlier designs allowed a role field on the key itself, which was dropped in the 2026-03-28 schema sync. The replacement model is safer: the key cannot exceed what its creator was allowed to do, and admins cannot accidentally create keys more powerful than their own account. The role, assigned_to, and assigned_name fields remain on ApiKeyView (deprecated, hardcoded to "member" / null) for backward compatibility while consumer code migrates.
* scope means "all creator permissions", not "all system permissions". A wildcard key held by a viewer still cannot create records. This avoids a class of privilege-escalation bugs where issuing a *-scope key to a guest account would grant admin-level access.
Shared lookupUserMembership() eliminates the double DB query. Previous code fetched user membership independently in permission resolution and context building — two queries per request. The shared helper is called once and its result is threaded through both paths.
Fallback to ROLE_IDS.member for null createdBy. Keys created before created_by was tracked (prior to the schema sync) have no creator reference. Falling back to member permissions matches the behavior those keys had previously, so existing integrations are not disrupted.
Related Modules
- Auth & Permissions (
features/tenant/auth.ts) -- RBAC role and permission system shared with API key resolution; see Auth & Permissions - Extraction (
features/entities/extraction/) -- external submission endpoint uses API key auth withextraction:submitscope - MCP (
features/mcp/) -- Amble MCP tool server authenticates via API key; usesresolveApiKeyPermissions()for tool gating - Webhooks (
features/webhooks/) -- external systems receiving webhooks often call back using API keys - Admin -- key management lives in Admin > API Keys
Webhooks
Outbound HTTP webhook endpoints for entity lifecycle events, with HMAC-SHA256 signature verification, Inngest-driven delivery, and automatic failure tracking.
Analytics and Cost Tracking
Fire-and-forget event recording for analytics, plus per-call AI cost tracking and runtime telemetry for prompt caching, reasoning, and context-management visibility.