License Tokens
Reference for the JWTs AI Coach mints when a buyer purchases access to a paid MCP server. For the step-by-step integration, see Publish a Paid MCP Server.
Algorithm
HS256 with a per-server signing secret. AI Coach and the publisher's server both hold the same 32-byte secret; the publisher's server verifies locally. There is no public JWKS — that would defeat HS256's symmetric model.
Publishers fetch the secret with GET /api/publisher/mcp/:id/signing-secret and rotate with POST on the same endpoint. Publishers who don't want to handle the secret can verify remotely via POST /api/mcp/licenses/verify.
Header
{
"alg": "HS256",
"typ": "JWT",
"kid": "<serverId>:<keyVersion>"
}
The kid format is <serverId>:<keyVersion>. Use it to pick the right secret after rotation — your server should accept both the current and previous key versions during the overlap window (default: up to 1 year).
Claims
{
"iss": "aicoach.pw",
"sub": "user_42", // buyer id, pseudonymous
"aud": "mcp_server:<serverId>",
"jti": "01HXKZ…", // token id; used for revocation lookups
"purchaseId": "01HXKY…",
"serverId": "01HXKW…",
"scope": "mcp:invoke",
"iat": 1731000000,
"exp": 1762536000 // default: iat + 1 year
} iss— always"aicoach.pw".sub— buyer's user id, prefixeduser_. Pseudonymous; never an email.aud—mcp_server:<serverId>. Verifiers must check this matches their own server.jti— unique token id. Use it as the key when polling the revocation feed.purchaseId— backlink to the underlyingmarketplace_purchasesrow.serverId— same as embedded inaud. Cross-checked against thekid.scope— always"mcp:invoke"in v1. Reserved for finer-grained future scopes.iat/exp— issued-at and expiry, Unix seconds. Default lifetime: 1 year.
Verification rules
A token is valid if and only if all of these hold:
- Three base64url segments; header decodes to a JSON object with
alg: "HS256"and a non-emptykid. - HS256 signature matches when computed with the secret for the
kid'skeyVersion. payload.serverIdequals the server portion of thekid.payload.serverIdequals your own server's id (prevents cross-server replay).expis in the future.- The
jtiis not in your local revocation cache (populated fromGET /api/mcp/licenses/revoked).
Error codes (remote verify)
| reason | meaning |
|---|---|
| malformed | Not three base64url segments, or header/payload isn't JSON, or required fields missing. |
| unknown_kid | No matching server + keyVersion row, or that key was retired. |
| bad_signature | HS256 verification failed. |
| expired | exp in the past. |
| revoked | Token's jti is in the revocation table. |
| server_mismatch | kid serverId doesn't match payload serverId, or doesn't match the serverId the caller passed. |
Remote verification
POST /api/mcp/licenses/verify
Content-Type: application/json
{
"token": "<jwt>",
"serverId": "<serverId>" // optional, recommended
} // 200 OK
{
"ok": true,
"jti": "01HXKZ…",
"serverId": "01HXKW…",
"claims": { ...full JWT payload... },
"revoked": false
} // 401 Unauthorized
{
"ok": false,
"reason": "expired" // see error table
} Revocation feed
GET /api/mcp/licenses/revoked?since=<iso>&serverId=<id>
since is required (ISO 8601 timestamp). serverId is optional but recommended — it filters to revocations affecting your server only. Capped at 1000 rows per response; nextCursor is non-null when truncated.
{
"since": "2026-05-01T00:00:00Z",
"serverIdFilter": "01HXKW…",
"count": 2,
"revocations": [
{
"id": "01HXKZ…", // the jti to refuse
"serverId": "01HXKW…",
"revokedAt": "2026-05-12T10:42:00Z",
"revokeReason": "refunded",
"expiresAt": "2027-05-12T10:42:00Z" // safe to drop after this
},
{
"id": "01HXL0…",
"serverId": "01HXKW…",
"revokedAt": "2026-05-12T11:01:00Z",
"revokeReason": "regenerated",
"expiresAt": "2027-05-12T11:01:00Z"
}
],
"nextCursor": null // non-null when paginated (1000 row cap)
} revokeReason is one of refunded (buyer refunded the purchase), regenerated (buyer rotated their token), publisher_request, or admin. You may want to log the reason for support, but the verification rule is identical regardless: refuse the jti.
Caching
The revocation feed has Cache-Control: public, max-age=60. With a 5-minute poll cadence + a 1-minute edge cache, your worst-case window between a refund landing and your server refusing the token is ~6 minutes. If you need tighter, drop the poll interval.
Why no public JWKS
HS256 is symmetric — publishing the verification key in JWKS would also publish the signing key, letting anyone mint tokens. v2 may introduce ES256 + JWKS for stateless edge verification; v1 sticks with shared secrets because they're simpler, well-understood, and let publishers verify with any HS256 JWT library in any language.
See also
- Publish a Paid MCP Server — integration walkthrough.
- Error codes — general API error reference.