Entitlement token format and verification
When an agent accepts an offer, the exchange issues an entitlement: a signed bearer grant for one piece of content, at one usage tier, until it expires. The agent presents it to retrieve the content and to report telemetry. Anyone can verify it offline with the exchange's public keys.
Code: src/entitlement/token.ts (issue and verify), src/entitlement/keys.ts (signer and key resolver interfaces).
Format
A compact JWS (RFC 7515) signed with EdDSA over Ed25519 (RFC 8037), with registered JWT claims (RFC 7519) plus one private claim, cx, for the licence scope.
Header:
{ "alg": "EdDSA", "typ": "cx-entitlement+jwt", "kid": "cx-MhcT0-9Ns0Yccd2i" }
Claims (decoded from a real token):
{
"iss": "http://localhost:8080",
"sub": "my-agent",
"aud": "http://localhost:8080",
"jti": "ent_9989d9af-4391-45ba-b6a8-5aac031eb7cc",
"iat": 1793184677,
"nbf": 1793184677,
"exp": 1793185577,
"cx": {
"v": 1,
"content_id": "fn-2026-0153",
"publisher_id": "pub_fieldnote",
"supply_id": "local-catalogue",
"usage": "display_snippet",
"terms": {
"citation_required": true,
"attribution": "Fieldnote Science",
"max_snippet_chars": 400,
"retention_seconds": 604800,
"ai_training": "prohibited"
},
"price": { "amount_micros": 5500, "currency": "USD" },
"clearing": { "publisher_price_micros": 5000, "fee_micros": 500, "fee_bps": 1000 },
"content_hash": "sha256:a1c66a16f0983ea78a692d54e809369efe2dfbf15dff8bfe3b506a4eb7f6dc7d",
"rate_card_ref": "rc_fieldnote@v1",
"offer_id": "off_0f0ef321-f551-42cc-bb22-cf95b7f6be9b",
"session_id": "52bb3cae-fe4f-4d40-8d5d-322f8c9d1c0e"
}
}
| Claim | Meaning |
|---|---|
iss, aud | Both the exchange's PUBLIC_URL (https://app.clearexchange.ai on the demo, whichever hostname the request came in on; see Hostnames). |
sub | The agent_id the entitlement was issued to. |
jti | Entitlement id (ent_<uuid>). Appears as entitlement_id in API responses, as license_ref in telemetry, and in the ledger. |
iat, nbf, exp | Issued at, not before, expiry (Unix seconds). exp = iat + ENTITLEMENT_TTL_SECONDS (default 900). |
cx.v | Scope version, 1. |
cx.content_id | The one item granted. For http-offer sources it is <source id>~<publisher's content id>. |
cx.publisher_id, cx.supply_id | Who supplies it, and through which source. |
cx.usage | Tier granted. It grants itself and every narrower tier. |
cx.terms | The resolved licence terms that were sold (the rate card's defaults merged with the tier's overrides). See Rate cards. |
cx.price | All-in price the agent pays, in micros. |
cx.clearing | The split fixed at offer time: publisher price and fee (sums to cx.price). |
cx.content_hash | Optional. sha256:<hex> of the full text the offer described. Retrieval fails with 502 supply_content_mismatch, and nothing is charged, if the source serves different text. |
cx.rate_card_ref | rate_card_id@vN of the card that priced it. |
cx.offer_id, cx.session_id | The accepted offer and the SPUR telemetry session. |
How the exchange verifies
verifyEntitlement() does these checks in order and stops at the first failure:
- Three base64url parts, otherwise
malformed. - Header
algmust beEdDSAandtypmust becx-entitlement+jwt, otherwiseunsupported_alg. The algorithm is pinned; the token never chooses it, which rules outalg: noneand algorithm confusion. kidmust name a known key, otherwiseunknown_key.- The Ed25519 signature must verify over
header.payload, otherwisebad_signature. No claim is trusted before this step. - Claims must have the expected shape and
cx.v= 1, otherwisemalformed. issandaudmust equalPUBLIC_URL(wrong_issuer,wrong_audience).- Time window:
now ≥ nbf(not_yet_valid) andnow < exp(expired). There is no clock leeway by default. Telemetry reporting accepts tokens up toTELEMETRY_GRACE_SECONDSpastexp; content retrieval never does. - Scope, when the caller asks:
cx.content_idmust match (wrong_content), andcx.usagemust be at least the tier needed (insufficient_usage).
On the HTTP API these failures become 401 entitlement_<reason>, except wrong_content and insufficient_usage, which are 403 (see Errors). POST /v1/entitlements/verify returns the same reasons in a 200 body.
Verifying offline
Publishers and other third parties can verify without calling the exchange:
- Fetch
GET {exchange}/.well-known/jwks.json(cache it; refetch on an unknownkid). - Split the token, decode the header, and check
alg == "EdDSA"andtyp == "cx-entitlement+jwt". - Select the JWK whose
kidmatches and verify the Ed25519 signature over the ASCII bytes of<header>.<payload>. - Only then decode the claims, and check
iss,aud,nbf,exp, and thatcx.content_idandcx.usagecover what is being requested.
A Node example with only node:crypto (the harness does the same in harness/jws.ts):
import { createPublicKey, verify } from "node:crypto";
const EXCHANGE = "https://app.clearexchange.ai";
const jwks = await (await fetch(`${EXCHANGE}/.well-known/jwks.json`)).json();
export function verifyEntitlement(token: string, contentId: string) {
const [h, p, s] = token.split(".");
const header = JSON.parse(Buffer.from(h, "base64url").toString());
if (header.alg !== "EdDSA" || header.typ !== "cx-entitlement+jwt") throw new Error("unsupported alg");
const jwk = jwks.keys.find((k: { kid: string }) => k.kid === header.kid);
if (!jwk) throw new Error("unknown key");
const key = createPublicKey({ key: jwk, format: "jwk" });
if (!verify(null, Buffer.from(`${h}.${p}`), key, Buffer.from(s, "base64url"))) throw new Error("bad signature");
const claims = JSON.parse(Buffer.from(p, "base64url").toString());
const now = Math.floor(Date.now() / 1000);
if (claims.iss !== EXCHANGE || claims.aud !== EXCHANGE) throw new Error("wrong issuer or audience");
if (now < claims.nbf || now >= claims.exp) throw new Error("not valid now");
if (claims.cx.content_id !== contentId) throw new Error("wrong content");
return claims;
}
Any JOSE library that supports EdDSA/Ed25519 works the same way; pin the algorithm rather than reading it from the token.
An http-offer publisher receives the agent's entitlement in the body of its retrieve call and can verify it this way before returning text (see Adding a source).
Keys
- The demo signs with one Ed25519 key from the
SIGNING_KEY_PEMsecret (PKCS#8 PEM;npm run keygenprints one). Thekidiscx-plus the first 16 characters of the public key'sx, so it stays the same across restarts and deploys. - Locally, with neither
SIGNING_KEY_PEMnorSIGNING_KEY_FILEset, an ephemeral key is generated at startup, and tokens stop verifying after a restart. Production refuses to start without a stable key. - Replacing the key invalidates every outstanding entitlement. There is no multi-key rotation yet.
- Token code depends only on the
SignerandVerificationKeyResolverinterfaces, so an attested signer can replaceLocalEd25519Signerlater without changing token logic (see Roadmap).
What an entitlement is not
- It is a bearer token: whoever holds it can retrieve the content until
exp. It is not yet bound to an authenticated agent key (Web Bot Auth binding is on the roadmap). - It is not a payment. The ledger records the price; no money moves.
- One offer yields at most one entitlement (
409 offer_already_accepted). Retrieving again with the same entitlement is not charged again.