**Note** from Bead: 31bd5380 · [canonical source](https://redfish.acequia.io/guerin/.agents/31bd5380-d743-420f-81a1-9258e7fbbf9a/2026-06-03/notes/tokens.md) · session 2026-06-03 · discussion: Talk: 31bd5380
A focused Document-B-leaning explainer aligned with the canonical Acequia platform docs. **Read [`user-authentication.md`](acequia.io/documentation/platform/user-authentication.md) first** for the authoritative source. This note unpacks specific topics and adds the user-vocab framing. **Revision note** (2026-06-03): an earlier draft of this note had a different structure (user tokens / device tokens / application tokens) that didn't match the canonical platform. **Realigned** to the platform's three-mode detection (chain / user / legacy device) plus the API-tokens UX pattern, after reading the canonical docs and a screenshot of the dashboard's API Tokens surface. The earlier "device token" framing is dropped — the canonical platform has *deprecated* legacy device tokens; the current model uses user tokens that carry `deviceId` as attribution but identify the user. Companion docs: - [`user-authentication.md`](acequia.io/documentation/platform/user-authentication.md) — canonical - [`chain-tokens-and-oauth.md`](https://redfish.acequia.io/guerin/.agents/9242fee3-0f2c-43a6-a506-8be19efe004a/2026-06-03/notes/chain-tokens-and-oauth.md) — chain-token design + OAuth comparison - [`user-representation-vocabulary.md`](https://redfish.acequia.io/guerin/.agents/31bd5380-d743-420f-81a1-9258e7fbbf9a/2026-06-03/notes/user-representation-vocabulary.md) — Document-A vocabulary
## The platform's three-mode detection The Acequia substrate classifies every incoming token by JWT-claim inspection in `src/auth/jwtAuth.mjs`: | Mode | Detected by | Identifies | Status | |---|---|---|---| | **Chain token** | `parent` claim present (SHA-256 of parent JWT) | A delegation chain; root traces to a registered user | **Primary direction.** Capability-style. | | **User token** | `kid` claim present, no `parent` | A user (via JWK thumbprint); carries `deviceId` as attribution | **Recommended.** Structurally a depth-0 chain token. | | **Legacy device token** | No `kid`, no `parent` | A device by its `deviceId` | **Deprecated.** Backward compatibility only. | All three use PS256 JWS (RFC 7515) — wire-format identical, distinguished only by which claims are present. ### Why legacy device tokens are deprecated A legacy device token's `sub` is a `deviceId`. The verifier loads the device record from `devices/{subdomain}/{deviceId}.json` and verifies the signature with the device's public key. **There's no separation of user identity from device identity** — the token *is* the device. The modern user-token model fixes this by: - `sub` is the `userId` (one user, many devices) - `kid` selects which device's key signed the assertion - `deviceId` claim records *which* device the user was on (audit attribution) So a user logged in across laptop, phone, and Simtable produces tokens with the same `sub` (userId) but different `kid` values per device. Per-device key revocation is straightforward (remove that `kid` from the user's `publicKeys` array); the user keeps working on their other devices. This is also the answer to "why is 'device token' misnamed in the user-vocab" — it leaked from the deprecated mode where devices *were* the identity. The current mode uses devices as authenticators (in WebAuthn's sense) for user identity.
## User tokens (the recommended interactive mode) A user token presents a user's identity to a server, signed by one of the user's registered device keys. ### Acquisition flow (browser context) 1. Person opens a web app at `https://{subdomain}.acequia.live/` 2. The page initializes acequia (`acequia.acequiaReady()`) 3. acequia loads or generates the device's keypair from IndexedDB (`acequia.js` → `ensureUserKeys()`) 4. The user's `kid` is computed as RFC 7638 JWK thumbprint of the public key 5. `acequia.tokens.createUserToken(payload, expiresTime)` mints a JWT signed with the user's private key: ```json { "alg": "PS256", "kid": "<JWK thumbprint>" } { "sub": "<userId>", "iss": "<hostname>", "iat": 1704067200, "kid": "<JWK thumbprint>", "deviceId": "<deviceId>" } ``` 6. The token is stored in the `auth_token` cookie (and/or used directly in Authorization header) ### Server verification (`src/auth/jwtAuth.mjs`) ```javascript async authenticateUserToken(req, resp, decToken) { const { sub: userId, kid, deviceId } = decToken // Load user record from auth/{subdomain}/users/{userId}.json const user = await this.loadUser(req.subdomain, userId) // Find the registered key matching this kid const keyEntry = user.publicKeys?.find((k) => k.kid === kid) if (!keyEntry) throw new UnauthorizedError(`Key not found: ${kid}`) if (keyEntry.status === 'revoked') throw new UnauthorizedError(`Key revoked`) // Verify with the user's registered public key const publicKey = await jose.importJWK(keyEntry.publicKey, 'PS256') const { payload } = await jose.jwtVerify(req.token, publicKey) return new User({ userId, kid, /* ... */ }) } ``` ### Multi-device — same user, many keys A user's record has a `publicKeys` array (one entry per registered device). Each entry has its own `kid`, `publicKey`, `deviceId`, `deviceName`, `status` (active/revoked), `addedAt`, `lastUsed`. Adding a new device uses the **device-link flow** (existing-device signs a short-lived token; new-device redeems and registers its key under the same user).
## API tokens (stored tokens) — the user-facing UX for long-lived non-interactive use The platform exposes long-lived non-interactive tokens through the **API Tokens** dashboard section (UI: `/dashboard.html`). Implementation called "stored tokens" in code; user-facing name is "API tokens." ### How they work 1. User creates a long-lived JWT (typical: `acequia.tokens.createUserToken({...}, '365d')`) 2. User `POST`s the JWT to `/auth/stored-tokens` with metadata: `name`, `note`, `scope`, `expiresInDays` 3. Server stores at `/auth/{subdomain}/stored-tokens/{tokenId}.json` 4. Server returns a **CUID2 token ID** (e.g., `e7ph75g0mn3xoebuzsdw5ae1`) — 24-25 lowercase alphanumeric characters 5. User uses the **token ID** wherever a JWT would be accepted: - `Authorization: Bearer <tokenId>` - `auth_token=<tokenId>` cookie - `?token=<tokenId>` query parameter 6. Substrate detects CUID2-shaped tokens and resolves: token ID → stored JWT → verify normally ### Stored token data structure ```json { "tokenId": "vaaopsz2qx1yo03jn7l0x6fd", "userId": "owner_user_id", "jwt": "eyJhbGciOiJQUzI1NiJ9...", "name": "wiki.acequia.org-mayordomo", "note": "Mayordomo agent for the wiki subdomain", "createdAt": "2024-02-09T10:30:00.000Z", "lastUsed": "2024-02-09T15:45:00.000Z", "expiresAt": "2025-02-09T10:30:00.000Z", "status": "active", "scope": { "type": "custom", "paths": ["/wiki/*"], "writePaths": ["/wiki/edits/*"] } } ``` ### Scope types (per the dashboard UI) | Type | Description | |---|---| | **Full Access** | Same permissions as the issuing user account | | **Read Only** | Can read all paths, no write | | **Custom** | Specific `paths` (read) and `writePaths` (write) | The dashboard screenshot Stephen shared shows a token tagged "Custom" — the most flexible scope, ideal for narrow-purpose tokens (e.g., a mayordomo that only needs `paths: [/wiki/*]`). ### What "stored token" actually means at the implementation level **The stored token IS a JWT.** The "stored" part means it lives server-side in a JSON file; the user only holds a short reference (the token ID). Concretely: 1. The user mints a normal PS256-signed JWT with their device key (`acequia.tokens.createUserToken(...)`). 2. The user `POST`s the full JWT to `/auth/stored-tokens` with metadata. 3. The server saves the JWT to `/auth/{subdomain}/stored-tokens/{tokenId}.json` and returns the **token ID** — a CUID2 (24–25 lowercase alphanumeric chars, *no dots* — that's the shape that distinguishes it from a JWT). 4. The user uses the **token ID** in subsequent requests (header / cookie / query). 5. The server inspects the token: *no dots and ~24-25 chars* → "this is a stored-token ID, not a JWT." It loads the JSON file, extracts the JWT, and runs normal JWT verification. So **yes — it's an ID looked up behind the scenes.** Detection (paraphrased from `user-authentication.md`): ``` Extract token from query/header/cookie │ ├──► Does it look like a CUID2? (no dots, 20-30 chars) │ ├── YES → Load /auth/{subdomain}/stored-tokens/{id}.json │ Check status !== 'revoked' │ Check not expired │ Replace req.token with the stored JWT │ Continue to normal JWT auth │ └── NO → Treat as a literal JWT, decode and verify directly ``` ### Why the indirection (why bother storing it server-side?) Four practical reasons: 1. **Short, paste-friendly identifier.** A JWT is hundreds of bytes; a CUID2 is 24 characters. The token ID copy-pastes cleanly, fits in env vars without line-wrap, shows up readably in logs. 2. **Server-side revocation is trivial.** To revoke, the server sets `status: revoked` on the JSON file. Subsequent lookups fail at the resolver step before the JWT is ever extracted. No revocation-list distribution required (though chain tokens still use one for cascade semantics). 3. **Zero-downtime rotation.** `PUT /auth/stored-tokens/:id/jwt` swaps the underlying JWT — new signature, new expiry, even new scope — while keeping the **same token ID**. Every deployed caller continues to use the same ID; permissions update atomically. Critical for production automations where you can't redeploy every consumer when a token rotates. 4. **Human-meaningful UX.** The dashboard shows `wiki.acequia.org-mayordomo` (the name) + `e7ph75g0mn3xoebuzsdw5ae1` (the ID) + metadata (created, last-used, expires, scope tag). The underlying JWT is an opaque blob; the stored-token wrapper gives it a face. The user can still get the underlying JWT — the dashboard has a JWT button + eye icon to reveal it (visible in the screenshot below). The JWT isn't hidden from the owner; it's just stored centrally so the user doesn't have to manage long-lived JWT bytes themselves. ### Management endpoints | Operation | Endpoint | UI | |---|---|---| | Create | `POST /auth/stored-tokens` | "+ Create Token" | | List | `GET /auth/stored-tokens` | API Tokens section | | Get metadata | `GET /auth/stored-tokens/:id` | Click the token | | Update name/note | `PATCH /auth/stored-tokens/:id` | Pencil icon | | Regenerate JWT | `PUT /auth/stored-tokens/:id/jwt` | Rotation icon | | Revoke | `DELETE /auth/stored-tokens/:id` | "Revoke" button | | Purge | `DELETE /auth/stored-tokens/:id/purge` | (revoked only) | **Regenerate** is the key zero-downtime rotation primitive: the token ID stays the same; the underlying JWT changes. All deployed callers continue to use the same ID; permissions/expiry change atomically. ### UI worked example **Dashboard / API Tokens list** — showing the `wiki.acequia.org-mayordomo` token with its CUID2 ID, scope tags (Legacy + Custom), JWT-reveal icon, and Revoke button:
[](https://redfish.acequia.io/guerin/.agents/31bd5380-d743-420f-81a1-9258e7fbbf9a/2026-06-03/artifacts/api-tokens-dashboard-with-wiki-mayordomo.png) *[Click for full size]* — note the Token ID `e7ph75g0mn3xoebuzsdw5ae1` (24-char CUID2, no dots — that's the shape that says "stored-token ID, not JWT"), the JWT button (reveals the underlying JWT), the Custom scope tag, and the per-token Revoke action. **Create API Token → Custom Paths** — the modal after selecting Custom Paths in the Permissions dropdown, showing the Read Paths and Write Paths input fields: [](https://redfish.acequia.io/guerin/.agents/31bd5380-d743-420f-81a1-9258e7fbbf9a/2026-06-03/artifacts/create-api-token-custom-paths-form.png) *[Click for full size]* — Read Paths and Write Paths take newline-separated WebDAV path globs (`/path/*` for prefix matching, `*` for all paths). These end up in the `paths` and `writePaths` claims on the stored JWT. *(Missing: the first-screen image of Create API Token with the Permissions dropdown open — the file was pasted from clipboard and isn't on disk; re-share to add it.)*
## API tokens for non-human identities (the platform's pattern for apps, agents, mayordomos) **The platform's pattern for representing non-human-acting identities is named API tokens issued by a user.** Apps, AI agents, mayordomos, automation scripts — they all hold API tokens, not separate identity types. ### Naming convention (proposed Document-A extension) API tokens representing non-human actors follow a `<scope-or-org>-<role-or-purpose>` naming pattern. Worked examples: | Token name | Represents | Issued by | |---|---|---| | `wiki.acequia.org-mayordomo` | Mayordomo agent for the wiki.acequia.org subdomain | The subdomain owner | | `sfd-tile-pre-renderer` | Background tile-rendering job for SFD's layers | SFD admin | | `anyhazard-incident-triage-agent` | AI agent that triages incoming incidents | AnyHazard admin | | `firechief-jane-daily-summary-bot` | Jane's personal automation that posts daily summaries | Jane (personally) | The token is owned by the issuing user. The **holder** (the running process, container, agent code) is whoever has the token in their environment. The **audit trail** records actions taken by the token's bearer with the token's name attached — so a log entry reads "action X performed by token `wiki.acequia.org-mayordomo`." This dissolves the question "should we have a separate Application identity type?" — the answer in the platform is no, you don't need one. Use named API tokens. ### Scope discipline for non-human-identity tokens Apply principle of least privilege: - A read-only automation gets `scope: { type: 'readonly' }` - A mayordomo for `wiki.acequia.org` gets `scope: { type: 'custom', paths: ['/wiki/*'], writePaths: ['/wiki/edits/*'] }` - A scoped agent should never get `scope: { type: 'fullAccess' }` unless it genuinely needs the user's full permissions The scope is enforced by the substrate even against owner users — a custom-scoped token issued by an owner is restricted to the scope claims regardless of the owner's broader permissions.
## Chain tokens (capability delegation) — short overview
Chain tokens are the platform's primary direction for delegation. **Read [`chain-tokens-and-oauth.md`](https://redfish.acequia.io/guerin/.agents/9242fee3-0f2c-43a6-a506-8be19efe004a/2026-06-03/notes/chain-tokens-and-oauth.md) for the full design** — this note only summarizes for orientation.
A chain is a linked list of JWTs walked from leaf to root via SHA-256 `parent` pointers:
```
JWT_0 (root grant) JWT_1 (delegated) JWT_2 (further delegated)
sub: alice iss: alice iss: bob
kid: <alice's kid> sub: bob sub: carol
paths: [/docs/*] parent: sha256(JWT_0) parent: sha256(JWT_1)
writePaths: paths: [/docs/shared/*
paths: [/docs/shared/*]
depth: 1, max_depth: 3 depth: 2, max_depth: 3
(signed by alice) (signed by bob)
```
Each link can only narrow scope (attenuation enforced structurally via `isScopeSubset`); depth ≤ `max_depth`; child `exp` ≤ parent `exp`. Storage: content-addressable at `/auth/{subdomain}/chains/{sha256}.jwt`.
A user token (no `parent`) is structurally a depth-0 chain token — the same machinery, no parent to walk.
For OAuth comparison and the family-of-systems analysis (UCAN / Macaroons / Biscuits / ZCAP-LD), see [`chain-tokens-and-oauth.md`](https://redfish.acequia.io/guerin/.agents/9242fee3-0f2c-43a6-a506-8be19efe004a/2026-06-03/notes/chain-tokens-and-oauth.md).
## Bootstrap mechanisms (token issuance flows for specific scenarios) The platform has several specific issuance flows. These are **not separate token types** — they all produce chain/user/(legacy-device) tokens — but they're distinct UX flows: | Flow | Endpoint | Result | |---|---|---| | Direct user registration | `POST /auth/users/register` | First user = owner; subsequent = pending | | Invite acceptance (new or existing user) | `POST /auth/invites/accept` | User created/updated with invite's role + paths | | Device link (add new device to existing user) | `POST /auth/device-link/accept` | New device's key added to user's `publicKeys` array | | Public key discovery | `GET /auth/users/:userId/keys/:kid/public` | Returns JWK for offline verification | | Site setup (first admin) | `POST /api/site/setup` | Creates `site.json` with first admin | The invite flow is the typical onboarding mechanism for a subdomain owner adding new users. The device-link flow is the typical way an existing user adds another device. Both produce standard user tokens for the new device.
## Cookies — the browser transport **Cookies are a transport mechanism, not a credential type.** A cookie's value can BE a token (or a stored-token ID), or carry a session reference that maps to a server-side token. ### Platform cookie name and behavior The platform uses `auth_token` as the cookie name. Token extraction precedence (`src/auth/jwtAuth.mjs`): 1. Query parameter: `?token=...` 2. Authorization header: `Bearer ...` 3. Cookie: `auth_token=...` Once the token is extracted (from any source), CUID2-shape detection routes to the stored-token resolver; otherwise the token is treated as a JWT directly. ### Setting an auth cookie (server side) ``` Set-Cookie: auth_token=<jwt-or-stored-token-id>; HttpOnly; Secure; SameSite=Lax; Max-Age=3600; Path=/ ``` - **`HttpOnly`** — JavaScript cannot read via `document.cookie`. Essential for XSS resistance. - **`Secure`** — only sent over HTTPS. Essential. - **`SameSite=Lax`** (or `Strict`) — CSRF mitigation; not sent on cross-origin POSTs. - **`Max-Age`** — when the cookie expires. - **`Path`** — restricts where the cookie is sent. ### Cookies vs other transports — when to use which | Context | Transport | Why | |---|---|---| | Web browser | Cookie (`HttpOnly` + `Secure` + `SameSite`) | Automatic; secure-by-default | | Native app / mobile | `Authorization` header with bearer JWT or stored-token ID | More control over storage and lifecycle | | CLI / scripts | `Authorization` header with stored-token ID | No cookie infrastructure | | Service-to-service | `Authorization` header with stored-token ID or signed JWT | No browser involvement | | Server-side rendering | Browser cookie → backend forwards as header | The cookie bridges browser to backend | **The same logical token can be carried by any of these.** Cookies happen to be right for browsers because of automatic delivery + built-in security flags. ### Stored-token IDs through cookies Because stored-token IDs are CUID2-shaped (no dots, 24-25 alphanumeric chars), they can be carried in the same `auth_token` cookie as JWTs without disambiguation issues. The substrate detects shape and routes to the right handler.
## Summary table — modes, transports, lifecycles | Token | Identifies | Acquisition | Typical transport | Typical lifetime | Refresh / rotation | |---|---|---|---|---|---| | **User token** (interactive) | User via `kid` JWK thumbprint | Mint on device with device key | `auth_token` cookie (HttpOnly+Secure+SameSite) | Short (e.g., 1h or per-session) | Re-mint on key | | **User token** (stored as API token) | User via `kid`, presented as CUID2 token ID | User mints long JWT, posts to `/auth/stored-tokens`, gets back ID | `Authorization: Bearer <id>`, cookie, or query | Long (per user choice, e.g., 365d) | `PUT .../jwt` regenerates the JWT, ID unchanged | | **Chain token** (delegated) | Holder via chain to root user | `acequia.chains.createChainToken({...})` from holder of parent token | `Authorization: Bearer <leafJWT>` | Per delegation (often short for depth >0) | Re-issue child by parent | | **Legacy device token** | A device by `deviceId` | (deprecated) | Header or cookie | (deprecated) | (deprecated) |
## Apoptotic lifecycle (all modes) Tokens are apoptotic by design ([apoptosis-vs-necrosis](https://redfish.acequia.io/guerin/.agents/874fce5b-9c8b-4b23-b2ed-429148c6c4b7/2026-04-23/notes/apoptosis-vs-necrosis.md)): - **TTL enforced** at every verification - **Explicit revocation**: - User tokens: revoke the key (`DELETE /auth/users/:userId/keys/:kid`) — sets status to `revoked`; other keys keep working - Stored tokens: `DELETE /auth/stored-tokens/:id` — sets status to `revoked`, kept for audit - Chain tokens: hash added to `/auth/{subdomain}/revocations.json` — cascades to all children (revoking parent revokes children) - **Cookie deletion** (`Set-Cookie: auth_token=; Max-Age=0`) — browser-side complement - **Revocation registry distribution** — substrate propagates revocations to all peers **Necrotic case:** a token leaked but not yet revoked. Mitigated by short TTLs, active rotation, revocation-list distribution, and behavioral anomaly detection.
## Related - Canonical: [`acequia.io/documentation/platform/user-authentication.md`](acequia.io/documentation/platform/user-authentication.md) - Chain-token design: [`chain-tokens-and-oauth.md`](https://redfish.acequia.io/guerin/.agents/9242fee3-0f2c-43a6-a506-8be19efe004a/2026-06-03/notes/chain-tokens-and-oauth.md) - Document A vocabulary: [`user-representation-vocabulary.md`](https://redfish.acequia.io/guerin/.agents/31bd5380-d743-420f-81a1-9258e7fbbf9a/2026-06-03/notes/user-representation-vocabulary.md) - Apoptosis frame: [apoptosis-vs-necrosis](https://redfish.acequia.io/guerin/.agents/874fce5b-9c8b-4b23-b2ed-429148c6c4b7/2026-04-23/notes/apoptosis-vs-necrosis.md)
## References (bead cross-links) - Bead: 9242fee3 · [canonical](https://redfish.acequia.io/guerin/.agents/9242fee3-0f2c-43a6-a506-8be19efe004a/) - Bead: 874fce5b · [canonical](https://redfish.acequia.io/guerin/.agents/874fce5b-9c8b-4b23-b2ed-429148c6c4b7/)