Skip to content

Call the API headlessly with a Personal Access Token

A logged-in user authenticates with a JWT from the browser login flow. A headless client — CI, a cron script, a customer’s ERP, Zapier — has no browser and no password prompt. A Personal Access Token (PAT) is the long-lived, revocable credential for that case: a kpat_… bearer token that reaches the same HTTP API (/api/write, /api/query, /api/command, /api/batch) a JWT does, but restricted to the scopes you granted it.

It is not a raw event-store key and not an app secret (that’s the secrets feature). A PAT authenticates a person against your handler API.

Add personal-access-tokens to APP_FEATURES and declare the scopes your deployment offers. A scope is two axes, like a GitHub fine-grained token: which domain × the level (read, or read + write). A domain may span features.

import { createPersonalAccessTokensFeature } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
createPersonalAccessTokensFeature({
scopes: {
// domain key → the QN globs each level grants
pages: { label: "Pages", read: ["managed-pages:query:*"], write: ["managed-pages:write:*"] },
ledger: { label: "Ledger", read: ["ledger:query:*"], write: ["ledger:write:*"] },
// omit `write` for a read-only domain — the UI then offers only read
reports: { label: "Reports", read: ["audit:query:list"] },
},
});

On the client, mount the management screen (list + create-with-one-time-reveal

  • revoke) — the only app-side UI step:
import { personalAccessTokensClient } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens/web";
createKumikoApp({
clientFeatures: [/* … */ personalAccessTokensClient()],
});

personal-access-tokens requires user and tenant; no secret or extra env var is needed (see Why no HMAC key).

A user mints their own token through the API (or the mounted screen). The plaintext is returned once — store it immediately, it is never retrievable again.

Terminal window
curl -sX POST https://app.example.com/api/write \
-H "Authorization: Bearer $USER_JWT" \
-H "Content-Type: application/json" \
-d '{
"type": "personal-access-tokens:write:create",
"payload": {
"name": "CI deploy",
"scopes": ["ledger:write", "pages:read"],
"expiresInDays": 90
}
}'
{
"isSuccess": true,
"data": { "id": "…", "token": "kpat_AbCd…", "prefix": "kpat_AbCd12" }
}

scopes are "<domain>:<level>" grants (level is read or write). expiresInDays is optional; omit it for a non-expiring token.

Send it as a bearer token to any of the four API routes. The middleware sees the kpat_ prefix and resolves the PAT before jwt.verify.

Terminal window
curl -sX POST https://app.example.com/api/write \
-H "Authorization: Bearer kpat_AbCd…" \
-H "Content-Type: application/json" \
-d '{ "type": "ledger:write:post-transaction", "payload": { /* … */ } }'

The token’s granted scopes expand to a set of QN globs. A dispatch whose type matches none of them is rejected before the handler runs:

{ "error": { "code": "access_denied", "message": "personal access token scope does not permit …" } }

Fail-closed also means: if you drop a domain from the app config after a token was minted, that token silently loses the capability — it never fails open. A JWT/cookie request is unaffected by scopes (it is bounded only by roles).

Terminal window
curl -sX POST https://app.example.com/api/write \
-H "Authorization: Bearer $USER_JWT" \
-H "Content-Type: application/json" \
-d '{ "type": "personal-access-tokens:write:revoke", "payload": { "id": "…" } }'

Revocation is effective on the next request — the resolver checks revokedAt (and expiresAt) on every call. List your own tokens (metadata only, never the hash) with personal-access-tokens:query:mine; list the scope catalog the app offers with personal-access-tokens:query:available-scopes.

  • Live roles. The resolver reads the user’s roles fresh from the DB on every request — a role downgrade or membership removal takes effect immediately, not at token expiry.
  • Tenant-bound. A token is fixed to the tenant it was created in; there is no cross-tenant switch via header.
  • Rate limited per token. Default 120 requests / 60 s per token; tune it with the rateLimit option on the feature.
  • Why no HMAC key. The secret is 32 random bytes, so only its SHA-256 is stored — no server-side hashing key to manage or rotate. High entropy makes a keyed hash (or a slow hash like argon2) unnecessary on the per-request auth path.