Bearer tokens for the public API — REST /api/v1/…, GraphQL
and MCP. One token works on all three; revoking it closes
all three.
The admin page is global, at /api-tokens in the sidebar’s directory group.
The routes behind it are served by @orthacms/identity-server.
Install and register
npm install @orthacms/api-tokens-admin// apps/admin/src/plugins.ts
import { ApiTokensPlugin } from '@orthacms/api-tokens-admin';
ApiTokensPlugin();No configuration, no environment variables.
Since 0.13.0 the page lists each token with who created it and when, the
workspaces it covers, and Last used; a live token within 30 days of its
expiry reads Expires soon with a countdown. Active (the default),
Expired and Revoked chips filter the list, and Search tokens looks at
names and the visible start of the token. New token asks for a name, the
workspaces, the access as two cards — Read-only (“Published records only.
Right for websites.”) or Full access — and an expiry that starts at 30
days. The secret is shown once with Try it: a ready curl request with
both headers, which Copy request copies whole, secret included. Revoke
says what stops first — when the token was last used and on which workspaces,
and, for full access, that writes stop too.
Scopes
| Access | scope | Permission keys |
|---|---|---|
| Read-only | read | content:read, media:read, segments:read |
| Full access | full | the above + content:create, content:update, content:publish, content:delete, media:create, segments:manage |
- The creator’s own permissions are never consulted.
- Neither scope carries
content:approve, webhooks, activity or user keys. - Reads serve published entries. Only
fullmay ask for drafts (status=draft|any). - A
fulltoken behind MCP can publish and delete. Issue the narrowest one.
Using a token
curl https://cms.example.com/api/v1/content/article \
-H 'Authorization: Bearer orthacms_Xb3k9Q…' \
-H 'X-Workspace-Id: 6b1f0c2e-8a4d-4f7b-9c3e-2d5a7e9f1b40' const cms = (path, init = {}) =>
fetch('https://cms.example.com/api/v1' + path, {
...init,
headers: {
Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN,
'X-Workspace-Id': process.env.ORTHACMS_WORKSPACE_ID,
...init.headers
}
});
const { items } = await (await cms('/content/article')).json(); X-Workspace-Id is required only when the token covers more than one
workspace. Copy the id from the workspace’s settings.
What goes wrong:
| Status | message |
|---|---|
401 | Missing `Authorization: Bearer <token>` header. |
401 | Invalid API token. — unknown, revoked or expired |
400 | This token covers 2 workspaces — name the one you want with the X-Workspace-Id header. |
403 | This token does not cover that workspace. |
403 | This token’s scope does not allow that operation. |
{ "statusCode": 401, "message": "Invalid API token.", "error": "Unauthorized" }Minting from a script
The admin page is the usual way. The same routes work with an administrator’s
session (tokens:* keys are admin-only):
curl -c cookies.txt https://cms.example.com/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@example.com","password":"…"}'
curl -b cookies.txt https://cms.example.com/api/api-tokens \
-H 'Content-Type: application/json' \
-d '{
"name": "Marketing site build",
"workspaceIds": ["6b1f0c2e-8a4d-4f7b-9c3e-2d5a7e9f1b40"],
"scope": "read",
"expiresAt": "2027-01-01T00:00:00.000Z"
}'| Body field | Rule |
|---|---|
name | 1–120 characters |
workspaceIds | 1–100 workspace uuids. Unknown id → 400 |
scope | read or full |
expiresAt | Optional ISO 8601. Omit = never expires. In the past → 400 |
Response — the only time secret is returned:
{
"id": "2e7a4c19-9b3d-4f60-8e21-5c7d0a9b1f38",
"name": "Marketing site build",
"workspaceIds": ["6b1f0c2e-8a4d-4f7b-9c3e-2d5a7e9f1b40"],
"scope": "read",
"lookupPrefix": "orthacms_Xb3k9Q",
"expiresAt": "2027-01-01T00:00:00.000Z",
"lastUsedAt": null,
"revokedAt": null,
"createdAt": "2026-09-30T10:12:04.881Z",
"secret": "orthacms_Xb3k9Q…"
}curl -b cookies.txt 'https://cms.example.com/api/api-tokens?workspaceId=<uuid>&status=active&q=build&page=1&pageSize=25'
curl -b cookies.txt -X DELETE https://cms.example.com/api/api-tokens/2e7a4c19-9b3d-4f60-8e21-5c7d0a9b1f38 # 204, idempotent| Route | Needs |
|---|---|
GET /api/api-tokens | tokens:read |
POST /api/api-tokens | tokens:create |
DELETE /api/api-tokens/:id | tokens:delete |
The list answers { items, total, page, pageSize, summary }. Since 0.13.0:
statusisactive,expiredorrevoked— derived fromrevokedAtandexpiresAt, revocation first.qsearches the name and the display prefix, case-insensitively.- Each item carries
createdBy: { id, name, email }. summaryis{ active, expired, revoked, expiringSoon, nextExpiresAt }, whereexpiringSooncounts active tokens expiring within 30 days. OnlyworkspaceIdnarrows it;statusandqdo not.
A secret is shown once
The secret is orthacms_ + 256 random bits. Only its SHA-256 hash is stored.
The list shows lookupPrefix so tokens can be told apart. Lost it? Revoke and
mint another.
Audit
Every use is logged as token.used, at most once per token per minute. The
list shows lastUsedAt.
See API tokens for presenting a token, and permissions for the keys.