Documentation source
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:
1. The permissions granted by the creating user's role in the tenant
2. 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:
```typescript
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:
1. Generates 32 random bytes, encodes as `sk_` + base64url
2. Extracts the first 12 chars as `key_prefix`
3. Hashes the full key with SHA-256
4. Stores the hash, prefix, scopes, and `created_by` (the current user's ID) in `api_keys`
5. 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_...` header
- `X-API-Key: sk_...` header
It calls `validateApiKey()`, which:
1. Hashes the provided key
2. Looks up the hash in `api_keys` (using admin client for cross-tenant access)
3. Checks the key is not revoked and not expired
4. Updates `last_used_at` (fire-and-forget)
5. Returns `ApiKeyAuthContext` (including `createdBy`) or `null`
### Permission Resolution
After authentication, API routes call `resolveApiKeyPermissions()` from `features/api-keys/lib/permissions.ts` to get the effective permission set:
```typescript
const permissions = await resolveApiKeyPermissions(apiKeyCtx, membership);
// permissions = intersection of user role permissions AND scope-derived permissions
```
The function:
1. Looks up the creating user's `roleId` via `lookupUserMembership()`. Falls back to `ROLE_IDS.member` if `createdBy` is null (keys created before the schema change).
2. Fetches the role's permission set via `getPermissionsForRole(roleId)`.
3. Expands the key's scopes to a permission set via `expandScopePermissions(scopes)`.
4. Returns the intersection (both conditions must be satisfied).
5. 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]/submit` using a key with `extraction:submit` scope
- CI/CD pipelines trigger extractions via keys with `extraction:trigger` scope
- Custom integrations read and write records via keys with `entities:read` / `entities:write` scopes
- MCP tool servers (e.g., Claude Code with the Amble MCP) authenticate with `tools:execute` scope 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.
## OAuth 2.1 (for browser MCP hosts)
API keys are the right answer for programmatic / server-to-server callers (CI scripts, n8n flows, agent runners). Browser-based MCP hosts — **Claude Cowork**, Claude.ai web/desktop/mobile — cannot accept a static bearer key; they require OAuth 2.1 with PKCE. Amble exposes both on the same MCP endpoint:
- **`Authorization: Bearer sk_…`** — API-key path (this doc).
- **`Authorization: Bearer at_…`** — OAuth-token path (see [Claude Cowork integration](/docs/integrations/claude-cowork) and ADR-0026).
OAuth tokens are issued per-user after a consent flow, inherit the consenting user's tenant permissions, and rotate every hour. Both paths converge on a unified `McpAuthContext` in `features/oauth/lib/middleware.ts` and reuse the same `API_SCOPES` vocabulary — there is no parallel scope system.
### Connecting via the admin UI
The **Admin → API Keys** page renders a "Connect via MCP" card with the **tenant-scoped** endpoint `https://app.sprinter.ai/api/mcp/t/{tenantSlug}/server` pre-filled for the tenant you're viewing (ADR-0063). It has copy-ready setup for both credential kinds — OAuth (Claude.ai, Cowork, ChatGPT) and API key (Claude Code, Cursor, Claude Desktop, n8n) — for each common client. For API-key callers the tenant-scoped URL additionally asserts the key's tenant matches the slug. Full reference: [MCP OAuth](/docs/integrations/mcp-oauth).
## Related Modules
- **Auth & Permissions** (`features/tenant/auth.ts`) -- RBAC role and permission system shared with API key resolution; see [Auth & Permissions](/docs/features/auth-permissions)
- **OAuth for MCP** (`features/oauth/`) -- OAuth 2.1 authorization server for browser MCP hosts; ADR-0026
- **Extraction** (`features/entities/extraction/`) -- external submission endpoint uses API key auth with `extraction:submit` scope
- **MCP** (`features/mcp/`) -- Amble MCP tool server authenticates via API key OR OAuth token; uses `resolveApiKeyPermissions()` for tool gating
- **Webhooks** (`features/webhooks/`) -- external systems receiving webhooks often call back using API keys
- **Admin** -- key management lives in Admin > API Keys