Features Apograph CMS on GitHub

API tokens

API tokens plugin @apograph/api-tokens-admin

Bearer tokens, the workspace bucket they cover, and the two scopes they come in.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

Every public API request authenticates with a bearer token:

GET /api/v1/content/article HTTP/1.1
Authorization: Bearer apograph_…
X-Workspace-Id: 3f7a…

Tokens are minted in the admin’s API Tokens page. The secret is shown once, at creation, and is stored hashed — there is no way to recover it later. Lose it and you mint a new one.

Scopes

ScopeGrants
readcontent:read. Published entries only.
fullThe complete content CRUD set — create, update, publish, delete.

The scope is turned into a permission set and evaluated by exactly the same policy that evaluates a logged-in user’s permissions. A route’s requirement means the same thing whichever kind of caller arrives, which is why adding a write route needed no change to the token guard.

Use read for anything that only renders content: a site build, a mobile app, a search indexer. full is for a migration script, a headless editor, or an external agent that authors.

The workspace bucket

A token is minted over one or more workspaces — its bucket. Each request targets exactly one of them.

SituationWhat happens
X-Workspace-Id present, in the bucketThat workspace is used.
X-Workspace-Id present, not in the bucket403. A token can never reach a workspace it was not minted for, whatever the header says.
X-Workspace-Id malformed400.
Header absent, bucket has exactly one workspaceThat workspace. The header is optional.
Header absent, bucket has several400, naming how many it covers.

That last row is deliberate: guessing a workspace would silently pick one for you, and “why is this response empty?” is a far worse failure than an explicit error.

A single-workspace token needs no header at all, which is what makes the common case a one-line fetch.

Reading drafts

A read token cannot see drafts. ?status=draft and ?status=any require a full token and are a 403 otherwise.

The widening is gated on the scope that could have published the row anyway, which is narrower than it first looks — and it exists because otherwise creating a draft over the API would produce a record the creator cannot read back: the write returns it once and it is then invisible forever.

# needs a full-scope token
curl 'https://cms.example.com/api/v1/content/article?status=any' \
  -H 'Authorization: Bearer apograph_…'

On a type that is not publishable, status is ignored — those rows have no publish state and are all live.

Failures

Every authentication failure is one bare 401: no header, wrong scheme, unknown token, revoked token, expired token. They are indistinguishable on purpose, so the endpoint cannot be used to probe which tokens exist.

Authorization failures are 403 and do say what was insufficient.

Managing tokens

RouteEffect
POST /api/api-tokensMint a token. Returns the secret once.
GET /api/api-tokensList tokens — metadata only, never a secret.
DELETE /api/api-tokens/:idRevoke immediately.

These are admin API routes: they take a session cookie and require the appropriate user permissions, not a bearer token. A token cannot mint another token.

Revocation takes effect immediately — verification is a database lookup on every request, not a signature check, so there is no window in which a revoked token still works.

A token is a workspace-wide credential

There is no per-type or per-entry scoping on a token, and no per-workspace role. A full token can create, update, publish and delete every granted type in every workspace in its bucket. Mint narrowly: one bucket per consumer, and read unless writing is genuinely required.

The same token drives GraphQL and MCP

One credential, three protocols. The same bearer authenticates POST /api/v1/graphql and — when the MCP plugin is enabled — POST /api/v1/mcp, where a full token lets an external agent do content CRUD. See the MCP endpoint.