Service accounts and API keys
This guide explains how to configure service accounts for automation, pipelines, and machine-to-machine integrations, as well as how to manage personal API keys.
Credential architecture
- Service Account: A machine identity bound to a specific project. Service accounts receive email-style identifiers (
<name>@<project-short>.cai.local) and hold assigned roles (memberoradmin). - API Key: Long-lived bearer token formatted as
cai_<keyid>_<secret>— a 12-character public key id ([0-9a-v]) and a 32-character secret. The platform stores only an argon2id hash, PHC-encoded with its own parameters ($argon2id$v=19$m=...,t=...,p=...$<salt>$<hash>), so a key hashed under today's settings keeps verifying after those settings change. The raw key is displayed once upon creation.
1. Create a service account
Project admin role is required to create and manage service accounts.
- platformctl
- curl
- Console UI
platformctl service-accounts create ci-deploy --role member --display-name "CI/CD Pipeline"
curl -sX POST "$CAI_API/v1/projects/$CAI_PROJECT/service-accounts" \
-H "Authorization: Bearer $CAI_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "ci-deploy", "display_name": "CI/CD Pipeline", "role": "member"}'
- Open Security → Service accounts in your project.
- Click Create service account.
- Enter
ci-deployas the name and select thememberrole. - Submit with Create service account.
2. Mint an API key (Copy-Once Dialog)
--expires-in-days accepts 1 to 3,650. It has no default: leave it off and the key never expires.
- platformctl
- curl
- Console UI
platformctl service-accounts keys create ci-deploy --expires-in-days 90
(sa is an alias for service-accounts, so platformctl sa keys create ... is the same command.)
Output:
key id: 7f3a9c1b2d4e
id: 0193b6f1-9f4c-7a1e-9a1e-5f2c8d0b4a77 (pass this to 'service-accounts keys revoke')
expires: 2026-11-13T09:22:00Z
cai_7f3a9c1b2d4e_9V2V3PFuKq7ZmT4xN1sJdR6cB0wYhLgA
copy this now - only a hash is stored, so it cannot be shown again. If it is lost, revoke this key and create another.
Two identifiers, and they are not interchangeable. key id is the public half — it is what audit lines record and what you match against the key sitting in your CI configuration. id is the row UUID, and it is what the revoke API path takes.
curl -sX POST "$CAI_API/v1/projects/$CAI_PROJECT/service-accounts/ci-deploy/keys" \
-H "Authorization: Bearer $CAI_TOKEN" \
-H "Content-Type: application/json" \
-d '{"expires_in_days": 90, "display_name": "github-actions-key"}'
Expected response (201 Created):
{
"key": {
"id": "0193b6f1-9f4c-7a1e-9a1e-5f2c8d0b4a77",
"key_id": "7f3a9c1b2d4e",
"display_name": "github-actions-key",
"kind": "service_account",
"created_at": "2026-08-15T09:22:00Z",
"expires_at": "2026-11-13T09:22:00Z",
"live": true
},
"secret": "cai_7f3a9c1b2d4e_9V2V3PFuKq7ZmT4xN1sJdR6cB0wYhLgA",
"note": "copy this now - only a hash is stored, so it cannot be shown again. If it is lost, revoke this key and create another."
}
secret is the whole credential and appears in this one response only, so capture the response once and take both fields out of it — .key.id is what the revoke path needs later:
CREATED=$(curl -sX POST "$CAI_API/v1/projects/$CAI_PROJECT/service-accounts/ci-deploy/keys" \
-H "Authorization: Bearer $CAI_TOKEN" \
-H "Content-Type: application/json" \
-d '{"expires_in_days": 90, "display_name": "github-actions-key"}')
echo "$CREATED" | jq -r .secret # the credential - store it now
KEY_UUID=$(echo "$CREATED" | jq -r .key.id)
-
Select the service account from Security → Service accounts.
-
Click Create key.
-
Set the expiration days (e.g.
90) and submit with Create key. Leaving the field empty means the key never expires. -
The next screen shows the key once. It offers:
- Copy: copy the raw secret to the clipboard.
- Download: save it as a credential file.
- Store in project Secrets: opt in to writing it into this project's Secrets Manager store under a suggested name.
The close button stays disabled until you tick the acknowledgement. Closing without copying leaves you with a key nobody can show you again — revoke it and create another.
3. Manage personal API keys
Personal API keys inherit your user permissions dynamically. If your account role changes or project access is updated, your personal API key permissions update instantly.
- platformctl
- curl
- Console UI
Mint a personal API key:
platformctl keys create --name cli-workstation --expires-in-days 30
The flag here is --name, not --display-name — the service-account form of this command is the one that takes --display-name.
List personal API keys:
platformctl keys list
Revoke one:
platformctl keys revoke <key>
curl -sX POST "$CAI_API/v1/users/me/keys" \
-H "Authorization: Bearer $CAI_TOKEN" \
-H "Content-Type: application/json" \
-d '{"display_name": "cli-workstation", "expires_in_days": 30}'
The response has the same shape as a service-account key: the credential in secret, the metadata in key.
Navigate to Security → My API keys to create, inspect, or revoke personal API keys.
4. Revoke API keys
Revoking an API key invalidates the credential immediately across all platform endpoints.
- platformctl
- curl
- Console UI
platformctl service-accounts keys revoke ci-deploy 0193b6f1-9f4c-7a1e-9a1e-5f2c8d0b4a77
The CLI accepts the key id here too (platformctl service-accounts keys revoke ci-deploy 7f3a9c1b2d4e) — it resolves that to the row UUID by listing the account's keys first. The API does not.
curl -sX DELETE "$CAI_API/v1/projects/$CAI_PROJECT/service-accounts/ci-deploy/keys/0193b6f1-9f4c-7a1e-9a1e-5f2c8d0b4a77" \
-H "Authorization: Bearer $CAI_TOKEN"
The last path segment is .key.id, the row UUID. Anything else — the 12-character key_id included — is refused with 400 invalid keyID: must be a UUID.
Click Revoke next to any active key row on the service account details page.
5. The credential inside a workload
Every deployed agent, function, MCP server and container carries a third kind of credential in its environment: CAI_PROJECT_KEY, formatted cai_wl_<keyid>_<secret>. It is minted and injected by the platform — you never create one.
It holds no project authority at all. The tenancy check refuses a workload key on both axes, resource and metadata, for its own project as much as anyone else's, before any grant is consulted. So every project route answers 404 not found for it — not 403. A leaked workload environment is not a foothold on the project API.
Its one capability is minting a short-lived, read-only token for its own project's secrets, at POST /v1/projects/{projectID}/secrets:issue-token, and only when the key's project matches the project in the path. That is what backs the call-time secret() read described in Use secrets in workloads.
If a workload has to call a project API for anything else, give it a credential that can: create a service account, mint a key for it, store that key with platformctl secrets put, bind the secret to an environment variable, and apply.
platformctl service-accounts create indexer-bot --role member
platformctl service-accounts keys create indexer-bot --expires-in-days 90
# copy the printed cai_... secret, then:
printf %s "$KEY" | platformctl secrets put indexer-bot-key
platformctl secrets bindings set my-agent CAI_TOKEN --secret indexer-bot-key
platformctl secrets bindings apply my-agent
Security fences for machine accounts
- No Privilege Escalation: Service account credentials cannot mint replacement credentials or alter project IAM memberships.
- Immediate Revocation: Deleting a service account automatically invalidates all associated API keys in a single transaction.
- Permanent Name Reservation: Deleted service account names are reserved permanently to prevent identity spoofing or grant inheritance.
- Workload Keys Hold No Authority: The
CAI_PROJECT_KEYinside a deployed workload is refused on every project route, by design, and can only mint a scoped secret-read token for its own project.