Skip to main content

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.

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

A key may be scoped to one project or left unscoped; policy_ids optionally narrow it further:

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: 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_id set): 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_id omitted 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 own policy_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, a project_id must 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_id and 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 calling list-projects.

  • Supply a project_id that matches the key's project and it is accepted.

  • Supply a project_id that belongs to a different project and the request is rejected with 403 and the API_KEY_PROJECT_SCOPE error code. The message names both the key's project and the requested one, and the 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 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:

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 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

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

The raw secret is never included in list or get responses — only the key_prefix is returned.

soat list-api-keys