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, prefixed user_. Pseudonymous; never an email.
  • audmcp_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 underlying marketplace_purchases row.
  • serverId — same as embedded in aud. Cross-checked against the kid.
  • 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:

  1. Three base64url segments; header decodes to a JSON object with alg: "HS256" and a non-empty kid.
  2. HS256 signature matches when computed with the secret for the kid's keyVersion.
  3. payload.serverId equals the server portion of the kid.
  4. payload.serverId equals your own server's id (prevents cross-server replay).
  5. exp is in the future.
  6. The jti is not in your local revocation cache (populated from GET /api/mcp/licenses/revoked).

Error codes (remote verify)

reasonmeaning
malformedNot three base64url segments, or header/payload isn't JSON, or required fields missing.
unknown_kidNo matching server + keyVersion row, or that key was retired.
bad_signatureHS256 verification failed.
expiredexp in the past.
revokedToken's jti is in the revocation table.
server_mismatchkid 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