API Keys
The API Keys module provides long-lived programmatic credentials for users. An API key authenticates as its owning user, is optionally scoped to a single project, and optionally restricts access to a subset of that user's policies.
Overview
API keys are prefixed with sk_ and are identified in the system by a public id prefixed with key_. The raw key value is returned only at creation time and cannot be retrieved again. A truncated key_prefix (first 8 characters) is stored for identification.
API keys use the standard Authorization: Bearer <key> header — the same as JWTs.
See the Permissions Reference for the IAM action strings for this module.
Related Tutorials
- Permissions in Practice - Step 6 (Create API keys)
- Permissions in Practice - Step 7 (Verify permissions)
Data Model
| Field | Type | Description |
|---|---|---|
id | string | Public identifier prefixed with key_ |
name | string | Human-readable label |
key_prefix | string | First 8 characters of the raw key (for identification, never the full secret) |
user_id | string | Public ID of the owning user |
project_id | string | null | Optional — the single project this key is scoped to, or null for an unscoped key that spans projects |
policy_ids | string[] | Optional — public IDs of policies that further restrict key permissions |
created_at | string | ISO 8601 creation timestamp |
updated_at | string | ISO 8601 last-updated timestamp |
Key Concepts
Permission Inheritance
A key may be scoped to one project or left unscoped; policy_ids optionally narrow it further:
| Configuration | Effective permissions |
|---|---|
project_id only | User permissions, restricted to that project |
project_id + policy_ids | Intersection of user policies and key policies, restricted to that project |
unscoped (no project_id) | User permissions, across every project the user can reach |
unscoped + policy_ids | Intersection of user policies and key policies, across every reachable project |
Intersection semantics: when a key has policy_ids, both the user's policies and the key's own policies must independently allow the requested action. The key can never exceed the permissions of the user who owns it — scoping to a project or leaving it unscoped only changes which projects the ceiling applies to, never raises it. See this ceiling demonstrated end to end in Permissions in Practice - Step 7 (Verify permissions), where a key granted a full-access policy is still limited to its owner's read-only permissions.
Project Scoping
project_id is optional.
- Scoped key (
project_idset): any request made with the key is hard-locked to its project; attempts to access resources in any other project are denied regardless of what the policies say. This binding is a hard boundary — see Project scope is a hard boundary, even for admins. - Unscoped key (
project_idomitted or null): the key is not confined to any project. It can operate across every project its owner can reach, bounded by the intersection of the owner's permissions and the key's ownpolicy_ids. Use IAM policies (on the user or the key) to control which projects and actions such a key may touch. Because an unscoped key has no implicit project, aproject_idmust be supplied explicitly on requests that operate on a specific project.
An update may re-scope a key to a different project, scope a previously unscoped key, or clear the scope with project_id: null. For a worked example of creating project-scoped keys, see Permissions in Practice - Step 6 (Create API keys).
Implicit project id
Because a project-scoped key already identifies its project, project_id is optional on requests made with such a key:
-
Omit
project_idand the request defaults to the key's project. An agent using a project-scoped MCP connector can upload a file, list files, create documents, etc. without first callinglist-projects. -
Supply a
project_idthat matches the key's project and it is accepted. -
Supply a
project_idthat belongs to a different project and the request is rejected with403and theAPI_KEY_PROJECT_SCOPEerror code. The message names both the key's project and the requested one, and themetacarriesscoped_project/requested_project:{"error": {"code": "API_KEY_PROJECT_SCOPE","message": "This API key is scoped to project 'proj_A' and cannot access project 'proj_B'. Mint a key scoped to 'proj_B' (or an unscoped key) to operate there.","meta": { "scoped_project": "proj_A", "requested_project": "proj_B" }}}
JWT auth is unchanged: a write that omits project_id still returns 400, since a concrete project is never inferred from a user's set of accessible projects.
Project scope is a hard boundary, even for admins
A key's project_id binding is enforced before, and independently of, the owner's role. An admin-owned key can create and delete projects (those gates are role-based and not tied to any project), but for ordinary resource operations — secrets, formations, files, webhooks, etc. — a project-scoped key is still confined to its own project. Admin role lifts the policy ceiling, never the project binding.
This means a single project-scoped key cannot both create a new project and provision resources inside it: create the project, then mint a key scoped to the new project (or use an unscoped key, bounded by IAM policy) to deploy into it. A cross-project resource write returns 403 API_KEY_PROJECT_SCOPE (above) rather than silently succeeding.
The boundary covers key management itself
Key creation is self-service — any authenticated caller may mint a key for themselves — so the project binding has to guard the credential being written, not only the resources being read. Requests made with a project-scoped credential are therefore confined on this module too:
| Operation | Behavior under a credential scoped to proj_A |
|---|---|
POST /api-keys with no project_id | Mints a key scoped to proj_A (the implicit project id) |
POST /api-keys with project_id: proj_B | 403 API_KEY_PROJECT_SCOPE |
POST /api-keys with project_id: null | 403 — minting an unscoped key requires an unscoped credential |
GET / PUT / DELETE /api-keys/{id} for a key in proj_B, or for an unscoped key | 403 API_KEY_PROJECT_SCOPE |
PUT /api-keys/{id} moving a proj_A key to proj_B, or clearing its scope | 403 — both ends of a re-scope are checked |
GET /api-keys (list) | Returns only keys scoped to proj_A |
Without this, the boundary would be exactly one call deep: a key confined to proj_A could mint an unscoped key for the same owning user and operate anywhere. Rotation still works — a scoped key can mint and delete keys within its own project.
Owner-or-admin still applies on top: the project check decides which keys a credential can see, and the owner check decides whether it may act on them.
Policy Attachment
Policies listed in policy_ids are loaded from the global Policies store. policy_ids is the list of policy public IDs (pol_-prefixed) attached to the key; the REST API accepts and returns these public IDs.
Revoking a Key
Delete the key via DELETE /api/v1/api-keys/:id. The key immediately stops authenticating. There is no rotation endpoint — create a new key and delete the old one.
Examples
Create an API key
- CLI
- SDK
- curl
soat create-api-key \
--name "CI/CD Pipeline" \
--project-id proj_V1StGXR8Z5jdHi6B \
--policy-ids pol_V1StGXR8Z5jdHi6B
// SDK
import { SoatClient } from '@soat/sdk';
const soat = new SoatClient({
baseUrl: 'https://api.example.com',
token: 'sk_...',
});
const { data, error } = await soat.apiKeys.createApiKey({
body: {
name: 'CI/CD Pipeline',
project_id: 'proj_V1StGXR8Z5jdHi6B',
policy_ids: ['pol_V1StGXR8Z5jdHi6B'],
},
});
if (error) throw new Error(JSON.stringify(error));
// data.key is the raw secret — store it securely, it is never returned again
curl -X POST https://api.example.com/api/v1/api-keys \
-H "Authorization: Bearer <jwt-token>" \
-H "Content-Type: application/json" \
-d '{
"name": "CI/CD Pipeline",
"project_id": "proj_V1StGXR8Z5jdHi6B",
"policy_ids": ["pol_V1StGXR8Z5jdHi6B"]
}'
Store the key value securely — it is never returned again.
List API keys
The raw secret is never included in list or get responses — only the key_prefix is returned.
- CLI
- SDK
- curl
soat list-api-keys
import { SoatClient } from '@soat/sdk';
const soat = new SoatClient({
baseUrl: 'https://api.example.com',
token: 'sk_...',
});
const { data, error } = await soat.apiKeys.listApiKeys();
if (error) throw new Error(JSON.stringify(error));
curl https://api.example.com/api/v1/api-keys \
-H "Authorization: Bearer <jwt-token>"