Skip to main content

API Keys

Long-lived programmatic credentials that authenticate as their owning user, optionally scoped to one project and optionally restricted to a subset of the user's policies.

Overview​

Keys are prefixed sk_ with a public id prefixed key_. The raw value is returned only at creation; the server never stores it, only its SHA-256 and a key_prefix (first 8 characters) for identification. A key is 32 random bytes, so a fast hash is enough to keep it unrecoverable, and a request pays one indexed lookup to verify it. Deleting a key refuses its next request. Keys use Authorization: Bearer <key>, like JWTs.

See the Permissions Reference for the IAM action strings for this module.

Data Model​

FieldTypeDescription
idstringPublic identifier prefixed with key_
namestringHuman-readable label
key_prefixstringFirst 8 characters of the raw key (for identification, never the full secret)
user_idstringPublic ID of the owning user
project_idstring | nullOptional — the single project this key is scoped to, or null for an unscoped key that spans projects
policy_idsstring[]Optional — public IDs of policies that further restrict key permissions
created_atstringISO 8601 creation timestamp
updated_atstringISO 8601 last-updated timestamp

Key Concepts​

Permission Inheritance​

Scope and policy_ids combine as:

ConfigurationEffective permissions
project_id onlyUser permissions, restricted to that project
project_id + policy_idsIntersection 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_idsIntersection of user policies and key policies, across every reachable project

Intersection semantics: with policy_ids, the user's policies and the key's must both allow the action; a key never exceeds its owner, and scoping only changes which projects the ceiling applies to. Demonstrated in Permissions in Practice - Step 7 (Verify permissions).

Project Scoping​

project_id is optional.

  • Scoped key (project_id set): hard-locked to its project; other projects are denied regardless of policy. See Project scope is a hard boundary, even for admins.
  • Unscoped key (project_id omitted or null): operates across every project its owner can reach, bounded by the intersection of the owner's permissions and policy_ids. Requests on a specific project must supply project_id explicitly.

An update may re-scope, scope, or clear scope with project_id: null. Example: Permissions in Practice - Step 6 (Create API keys).

Implicit project id​

With a project-scoped key project_id is optional on requests:

  • Omitted: defaults to the key's project, so a project-scoped MCP connector can upload files, list files, create documents, etc. without list-projects.

  • Matching the key's project: accepted.

  • A different project: 403 with the API_KEY_PROJECT_SCOPE error code; the message names both projects and meta carries scoped_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 omitting project_id returns 400; a project is never inferred from a user's accessible set.

Project scope is a hard boundary, even for admins​

The project_id binding is enforced before, and independently of, the owner's role. An admin-owned key can create and delete projects (role-gated, not project-tied), but for resource operations (secrets, formations, files, webhooks, etc.) it stays confined to its project.

So one scoped key cannot both create a project and provision inside it: create the project, then mint a key scoped to it (or use an unscoped key). A cross-project resource write returns 403 API_KEY_PROJECT_SCOPE.

The boundary covers key management itself​

Key creation is self-service, so the binding also guards the credential being written. Under a credential scoped to proj_A:

OperationBehavior under a credential scoped to proj_A
POST /api-keys with no project_idMints a key scoped to proj_A (the implicit project id)
POST /api-keys with project_id: proj_B403 API_KEY_PROJECT_SCOPE
POST /api-keys with project_id: null403 — minting an unscoped key requires an unscoped credential
GET / PUT / DELETE /api-keys/{id} for a key in proj_B, or for an unscoped key403 API_KEY_PROJECT_SCOPE
PUT /api-keys/{id} moving a proj_A key to proj_B, or clearing its scope403 — both ends of a re-scope are checked
GET /api-keys (list)Returns the caller's own keys in proj_A, or every key in proj_A when the credential holds api-keys:ListApiKeys on the project

Otherwise a key confined to proj_A could mint an unscoped key for the same owner and operate anywhere. Rotation still works within the key's own project.

Owner-or-admin applies on top: the project check decides which keys are visible, the owner check whether the caller may act on them. The listing is narrowed to the caller's own keys by default (confinement to a project is not authority over it); a credential holding api-keys:ListApiKeys on the project reads the whole inventory.

Policy Attachment​

policy_ids are pol_-prefixed public IDs from the global Policies store; the REST API accepts and returns them.

Revoking a Key​

DELETE /api/v1/api-keys/:id stops the key immediately. There is no rotation endpoint: create a new key, delete the old.

Examples​

Create an API key​

soat create-api-key \
--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​

List and get responses carry only key_prefix, never the raw secret.

soat list-api-keys