# API tokens and scopes

> > Audience: developers integrating with CBX, and the admins who hand them tokens · > Scope: the one credential every API caller presents, and the vocabulary …

Source: CBX documentation, version 4.0 preview (unreleased). Canonical page: https://docs.configbox.at/docs/4.0-preview/technical/api_tokens_and_scopes. Last updated 2026-08-25.

---
> **Audience:** developers integrating with CBX, and the admins who hand them tokens ·
> **Scope:** the one credential every API caller presents, and the vocabulary that says what it
> may do · **Since:** 3.8.17 (the `<area>:<level>` vocabulary; tokens themselves since 3.7.2)

**TL;DR** — A caller of the REST API (`/cb-api/v1/…`) or of MCP over HTTP (`/cb-api/v1/mcp`)
presents a **bearer token**. A token carries **scopes**, each `<area>:<level>` — `catalog:write`,
`orders:read`. Seven areas say *what* the token may touch; two levels say *how*. Write covers read
within an area; nothing carries across areas; every token can read store configuration; and
permission to run PHP (`code:write`) is never part of any bundle. Tokens are minted in the admin
(**ConfigBox → API Tokens**) or on the CLI (`configbox:token:mint`), shown once, and revoked
permanently. `configbox:token:scopes` prints this whole vocabulary from the running code.

---

## 1. What a token is

```
cbx_<id>_<secret>
 |    |     |
 |    |     +-- 43 characters of CSPRNG output. Never stored - only its SHA-256 is.
 |    +-------- the row id, so verifying is one indexed read and not a table scan
 +------------- a fixed prefix, so a leaked token is RECOGNISABLE as one by a secret scanner
```

A token is presented as a standard bearer credential (RFC 6750):

```
Authorization: Bearer cbx_12_XNySf3kCGeGmN4TUkU6gzkDEzgEUWzmQwtm9fww
```

- **Shown once.** Only the hash is kept, so nothing can display it again — not the admin screen,
  not the CLI, not a support engineer with database access. Lost it? Revoke and mint another.
- **Expires** optionally, at the start of a UTC day. Prefer an expiry for anything run by an
  outside party: a token that ends by itself does not rely on anyone remembering to revoke it.
- **Revoked** permanently. The row stays, so the record of what existed survives; the caller is
  shut out on its next request.
- **Last used** is recorded (best-effort) so the "nobody uses this any more" conversation has data.

A token is for **software**. Staff log in to the admin with their own account; a token never opens
an admin screen.

## 2. Where tokens are minted

**In the admin** — **ConfigBox → API Tokens → Add**. Name it after its holder, tick the scopes in
the grid (or start from a preset button), optionally set an expiry, save. The screen shows the new
token *once* in a warning panel; copy it there. The admin-guide article
`docs/admin-guide/settings/manage-api-tokens.md` walks a store admin through it in plain language,
and the same screen's help drawer shows it in place.

**On the CLI** — from the site root, under the site's own PHP:

```bash
php cli/joomla.php configbox:token:mint "catalog assistant" --preset=author
php cli/joomla.php configbox:token:mint "fulfilment bot" --scope=orders:write --scope=customers:read
php cli/joomla.php configbox:token:mint "pricing dev" --preset=author --scope=code:write --expires="+90 days"

php cli/joomla.php configbox:token:scopes      # the vocabulary, from the code
php cli/joomla.php configbox:token:list        # what exists, with last use
php cli/joomla.php configbox:token:revoke 12
```

The token is the last line of `mint`'s output, unstyled, so `… | tail -1` captures exactly it.
Both routes call the same `ConfigboxApiTokenHelper::mint()`; there is no third way to make one.

## 3. The seven areas

A scope is `<area>:<level>`. The area is what a token touches; the level is how.

| Area | Holds | Entities | Grantable | If a mistake happens |
|---|---|---|---|---|
| `catalog` | What the shop sells: products, pages, questions, answers, lists and assignments, detail panes, examples, rules, formula and matrix calculations. | `product`, `page`, `question`, `answer`, `product-list`, `product-list-assignment`, `calculation`, `calculation-code`, `calculation-matrix`, `product-detail-pane`, `example`, plus any customization model that does not say otherwise | read, write | **recoverable** — a typo someone fixes; the storefront shows all of it anyway |
| `store` | How the shop charges and delivers: settings, store identity, currencies, tax classes, shippers and shipping methods, payment methods and providers, connectors, notifications, salutations, geography. | `settings`, `store`, `currency`, `tax-class`, `shipper`, `shipping-method`, `payment-method`, `payment-provider`, `connector`, `notification`, `salutation`, `country`, `state`, `county`, `city`, `zone` | write only — **every token reads it** (§4, rule 3) | **costly** — a wrong tax rate is an invoice that already went out |
| `customers` | People: customer records, addresses, reviews, parties and their relationships. | `customer`, `address`, `review`, `party`, `party-relationship` | read, write | **irreversible** — personal data; a leak cannot be un-leaked |
| `orders` | Money that moved: sales orders, quotes, their lines and tax rates, payments. Frozen checkout snapshots — readable, and changed only through operations. | `sales-order`, `order-line`, `order-line-config`, `order-tax-rate`, `quote`, `quote-line`, `quote-line-config`, `quote-tax-rate`, `payment` | read, write | **irreversible** — who bought what, plus their address |
| `conversations` | What visitors said to the advisor: conversations, turns, events, recommendations, outreaches. | `chat-conversation`, `chat-turn`, `chat-event`, `chat-outreach`, `chat-recommendation`, `chat-recommendation-evidence`, `chat-recommendation-note` | read, write | **irreversible** — conversational personal data |
| `code` | PHP the pricing engine evaluates: *code* calculations, and transfer packages that carry them. | — (a permission, not a record set) | write only — reading a code calculation is `catalog:read` | **dangerous** — code execution on the server |
| `system` | The install itself: migrations, charset, system variables, orphaned strings. | — (operations, not records) | read, write | **costly** — can freeze updates or skip migrations; recoverable from a backup |

Twelve scopes are grantable: `catalog:read`, `catalog:write`, `store:write`, `customers:read`,
`customers:write`, `orders:read`, `orders:write`, `conversations:read`, `conversations:write`,
`code:write`, `system:read`, `system:write`.

**Why areas and not verbs.** The first vocabulary was `read` / `write` / `admin`. It split by what a
token *does*, and the boundaries that matter run by what it *touches*: a catalog browser must not
dump customer addresses, an order-fulfilment token must not run migrations, and authoring a product
must not include writing PHP the engine evaluates. Every one of those is a subject boundary a verb
vocabulary cannot express. The pattern is Shopify's (`read_products` / `write_products`) and Stripe's
restricted keys (a matrix of resources × none/read/write) — the closest prior art for a shop.

## 4. The four rules

These are the entire model. They live in one function, `ConfigboxApiTokenHelper::grantIncludes()`,
which the REST controller and the MCP server both consult — so HTTP tokens and stdio runs cannot
disagree about what a grant means.

```mermaid
flowchart LR
  REST["REST\nGET /cb-api/v1/customer/7"] -- "customers:read ?" --> G
  MCPH["MCP over HTTP\ncbx_get_entity customer"] -- "customers:read ?" --> G
  STDIO["MCP over stdio\n--preset=fulfilment"] -- "customers:read ?" --> G
  G["grantIncludes(granted, scope)\n1 write ⊇ read within the area\n2 nothing across areas\n3 store:read is the floor"]
  G -- "yes" --> OK["the model runs"]
  G -- "no" --> NO["403 INSUFFICIENT_SCOPE\nor a tool error naming the scope"]
```

1. **Within an area, write implies read.** `catalog:write` can describe, list and get the catalog. A
   token is stored as the highest level per area: minting `catalog:read,catalog:write` stores
   `catalog:write`.
2. **Across areas, nothing implies anything.** Seven independent switches. `read,admin`-style
   ladders are gone: `orders:write` says nothing about the catalog, and `customers:write` nothing
   about orders.
3. **`store:read` is the floor.** Every token has it, ungranted and unrevokable — it is not a scope
   `mint` accepts. Currencies, tax classes, shipping methods and countries are the vocabulary every
   other area is expressed in; a token that could not read them could not make sense of a price, a
   product or an address. The floor is safe **only because credential-bearing fields are
   `apiSensitive`** and never returned by any read — a payment method's provider parameters, a
   payment provider's settings, the licence key, the AI and MaxMind keys. A model that adds a
   secret to a store-area entity must mark it, or the floor hands it to every token.
4. **`code:write` is never enough on its own.** Writing a *code* calculation needs `catalog:write`
   **and** `code:write`; importing a transfer package that carries code calculations needs both too.
   `cbx_check_product_package` reports `carriesCode` and the scopes still needed before anything is
   written. No preset includes `code:write` — it is always spelled out by hand.

## 5. Presets

A preset is a name `mint` expands to a scope list **and then forgets**. The token row stores the
explicit scopes, so a preset can be renamed or retired without touching a token, and there is never
a second vocabulary to keep in sync.

| Preset | Scopes | For |
|---|---|---|
| `author` | `catalog:write` | Building and maintaining the catalog. |
| `fulfilment` | `orders:write`, `customers:read` | Working orders, and seeing who they belong to. |
| `insights` | `catalog:read`, `conversations:read` | Learning from the advisor: what visitors ask, against what the catalog offers. |
| `operator` | `store:write`, `system:write` | Keeping the install healthy: migrations, charset, shipping and tax configuration. |
| `everything` | every area but code, at write | A supervised session. Still cannot write PHP. |

`--preset` combines with `--scope`: `--preset=author --scope=code:write` is the whole ceremony for a
token that may author PHP calculations, and it is exactly enough.

## 6. What each token actually gets

The same token means the same thing on both surfaces. Over REST, an entity endpoint needs the
entity's area at the request's level (`GET` → `<area>:read`, `POST`/`PATCH`/`DELETE` →
`<area>:write`); over MCP, the generic entity tools are opened by *any* scope at their level and then
gated per entity, and every entity enum a token is handed lists only what that token may touch.

| Token | Over REST | Over MCP |
|---|---|---|
| `catalog:write` (`author`) | Full CRUD on the 13 catalog entities; `GET` on store entities; 403 on customers, orders, conversations | 16 tools; the write enums list the 9 writable catalog entities; refused reading a customer (`customers:read`) and writing a code body (`code:write`) |
| `orders:write` + `customers:read` (`fulfilment`) | `GET` on orders, customers, store; 403 on products | 5 tools — describe/list/get, `cbx_set_order_status`, `cbx_clear_cache`; the generic writers are withheld because no order entity is writable |
| `catalog:read` + `conversations:read` (`insights`) | `GET` on catalog, conversations, store | The read tools with both areas in their enum; no writers |
| `store:write` + `system:write` (`operator`) | CRUD on store entities; 403 on the catalog | `cbx_apply_migrations`, `cbx_convert_charset`, …; refused `cbx_get_entity product` |
| `everything` + `code:write` | Everything | All 23 tools |
| only the floor (e.g. `conversations:read`) | `GET /tax-class` 200; `GET /payment-method/1` 200 **without** `params` | Store entities readable; no credentials in any of them |

**The discovery index is filtered too:** `GET /cb-api/v1/` lists only the entities the presenting
token may read. Any valid token may call discovery, `openapi.json` and the schemas — they describe
structure, not data.

## 7. Refusals

| Situation | REST | MCP |
|---|---|---|
| No token / bad token | `401` with `WWW-Authenticate: Bearer`, one message for missing, malformed, revoked and expired | `401` at the HTTP layer, before any JSON-RPC conversation |
| Token lacks the scope | `403 INSUFFICIENT_SCOPE` naming the exact scope: `This token does not carry the "orders:read" scope.` | A tool error the model can act on: `Reading "customer" needs the "customers:read" scope, and this connection carries only: catalog:write.` |
| A tool the token is not offered, called by name | — | Refused just the same — listing is a hint, the gate is next to the work |
| Code body without `code:write` | — | `Writing a code calculation is running PHP on this server, and needs the "code:write" scope on top of catalog:write. …` |

403 rather than 401 when the token is valid but too narrow, because retrying with the same
credentials can never help — that distinction is what a client's retry logic reads.

## 8. The audit trail

Every MCP `tools/call` writes one line to `configbox_authorization.log` (the `authorization` KLog
category, alongside every other who-was-allowed-what decision): the tool name, the caller (a token's
id and name over HTTP, `stdio` on the CLI), and the granted scopes — **never the arguments**, which
can carry a customer's address. `Last used` on the token row is the coarse version of the same
record.

## 9. The legacy names, and migration 3.8.17

`read`, `write` and `admin` are accepted by `mint` for **one release**, expanded with a printed
notice, and never stored:

| Legacy | Expands to | Why this mapping |
|---|---|---|
| `read` | `catalog:read`, `customers:read`, `orders:read`, `conversations:read`, `system:read` | It was "everything readable". Narrow it afterwards. |
| `write` | `catalog:write`, `code:write` | A `write` token could author PHP calculations; keeping that is what makes the migration lossless. **Revoke `code:write` from tokens that do not need it — most.** |
| `admin` | `store:write`, `customers:write`, `orders:write`, `system:write` | The grab-bag, unpacked into its four jobs. |

Migration `3.8.17` widens the `scopes` column to `VARCHAR(512)` and rewrites every stored token
through the same normalisation, idempotently. It logs a **warning** naming each token that came out
holding `code:write`, because that is the one line an admin should act on. A `write` token that had
no `read` widens slightly — `catalog:write` now reads the catalog, by rule 1.

## 10. For customization authors

A model that opts into the API with `getEntityName()` belongs to an area. The central map
(`ConfigboxApiTokenHelper::getEntityAreas()`) covers every stock entity and falls back to `catalog`
for one it does not know — right for the customization models that exist today (product content
such as summary-panel images), and wrong for a custom model holding customer data, which would then
be readable by any catalog token. Override `getApiArea()` on the model and return one of
`ConfigboxApiTokenHelper::AREA_*`. See `customization/com_configbox_overriding_controllers_and_models.md`.

## 11. Where the pieces live

| What | Where |
|---|---|
| The vocabulary — areas, scopes, floor, presets, entity map, `grantIncludes()` | `helpers/apitokens.php` (`ConfigboxApiTokenHelper`) |
| Mint / verify / revoke, the token format | same file |
| REST enforcement per entity | `controllers/apiv1.php` (`authenticateForEntity()`) |
| MCP enforcement per tool and per entity, tool annotations | `helpers/mcp.php` (`getToolScopes()`, `getToolAnnotations()`, `getModelForEntity()`) |
| The admin screen | `models/apitokens.php`, property type `apitokenscopes` (the grid) |
| The CLI | `configbox:token:mint`, `token:list`, `token:revoke`, `token:scopes` — `com_configbox_cli_commands.md` |
| The migration | `helpers/updates/3.8.17.php` |
| The pinning tests | `tests/specs/api/mcp-http-transport.spec.ts`, `entity-api.spec.ts`, `cli/cli-commands.spec.ts` |

Related reading: `com_configbox_api_surfaces.md` (the four ways in), `com_configbox_entity_api.md`
(using REST), `com_configbox_mcp_server.md` (using MCP), `admin-guide/settings/manage-api-tokens.md`
(the store admin's view of the same screen).
