Documentation
Verdict tokens
Every verdict is a signed ES256 receipt. Verify authenticity, bind context, then authorize: three separate checks.
verdictToken is an ES256 JWT decision receipt minted by the API and signed with a key from GET /.well-known/jwks.json (no authentication needed there).
When signature and claim validation succeed, the token provides cryptographic evidence that Chitmark issued the stated verdict for the specified tenant and session and that the signed contents have not been modified. It does not by itself make a JWT single-use: the same token can be presented to multiple downstream services within its lifetime. Track and invalidate accepted jti values only where your flow requires one-time use.
The token is evidence. Your backend's expected session and tenant context is what makes that evidence relevant to this request.
Correct:
Client
↓ receives verdictToken
Backend
↓ verifies signature + iss + tenant + session
↓ then checks non-degraded allow
honors decision
Never:
Client → "decision=allow" → Backend
Prefer not:
Client → token → Backend → trust token without
expected session/tenant binding
Own the token server-side before your backend honors
an allow from a client.Three layers
| Layer | What you check |
|---|---|
| Authenticity | ES256 signature, JWKS / kid, iss, iat / exp |
| Context binding | tid → expected tenant; sub → expected session; aud → optional origin constraint (never alone) |
| Authorization | decision === "allow" and degraded === false (and enforcementMode === "enforce" from the Verdict body when you have it) |
JWT validation alone is not authorization. Binding alone is not authorization. Both must succeed, then the decision must be an eligible allow.
Claims
| Claim | Layer | Meaning | |||
|---|---|---|---|---|---|
iss | Authenticity | Issuer: always https://api.chitmark.com | |||
iat / exp | Authenticity | Minted / expiry (5 minutes). Intentionally short-lived: verify at consumption time; do not persist as a durable authorization credential. | |||
jti | Authenticity | Unique token identifier; use with replay tracking where one-time use is required | |||
tid | Context binding | Issuing tenant, server-derived from the API key | |||
sub | Context binding | Session the token is bound to | |||
aud | Context binding (optional) | Origin hint from request headers. Never use as the sole authorization binding. | |||
eventId | Decision payload | The verify event the decision belongs to | |||
decision | Authorization | allow \ | challenge \ | deny | |
degraded | Authorization | When true, decision is always challenge | |||
actorType | Decision payload | human \ | agent_principal \ | agent_abusive \ | unknown |
confidence | Decision payload | Model confidence for this decision (0..1); not a calibrated probability of abuse |
Verdict tokens are intentionally short-lived. A downstream service should verify the token at the time it is consumed rather than persist it as a long-lived authorization credential. Store eventId (and your own account state) for lasting records; the JWT is a receipt for a moment, not a standing "this user was approved" grant.
enforcementMode is returned on the Verdict body and stored on the event ledger; it is not a JWT claim. Pair token verification with the mode from the verify response (or your own gate) before authorizing.
Verify and authorize in TypeScript
Always provide the expected session and tenantId: they are the context-binding boundary, not optional hints. aud optionally constrains origin. Authenticity is not authorization: check the decision after claims verify.
import { Chitmark } from "@chitmark/sdk";
const claims = await chitmark.verifyVerdictToken(token, {
session, // token sub must match
tenantId, // token tid must match
// aud: "app.acme.com/signup", // optional: enforce origin hint
});
// Authenticity + binding passed. Now authorize:
if (claims.degraded || claims.decision !== "allow") {
throw new ChallengeRequiredError(); // HTTP 428 / challenge response
}
// Only now trust the allow.
// Rejects authenticity failures with VerdictTokenError:
// malformed_token, wrong_algorithm, wrong_type, unknown_kid,
// invalid_signature, expired, not_yet_valid, wrong_issuer,
// wrong_session, wrong_tenant, wrong_aud, jwks_unavailableVerify and authorize in Python
Install the optional extras first: pip install "chitmark[verdict]". Always pass session and tenant_id: they are required keyword arguments.
from chitmark import verify_verdict_token
claims = verify_verdict_token(
token,
session=session, # token sub must match
tenant_id=tenant_id, # token tid must match
# aud="app.acme.com/signup", # optional: enforce origin hint
)
# Authenticity + binding passed. Now authorize:
if claims["degraded"] or claims["decision"] != "allow":
raise ChallengeRequiredError() # HTTP 428 / challenge response
# Only now trust the allow.
# Rejects authenticity failures with VerdictTokenError:
# malformed_token, wrong_algorithm, wrong_type, jwks_unavailable,
# unknown_kid, invalid_signature, expired, not_yet_valid,
# wrong_issuer, wrong_session, wrong_tenant, wrong_audJWKS and key rotation
Algorithm: each JWKS entry is an EC P-256 public key for ES256. Require JWT alg === "ES256" before verifying (wrong_algorithm otherwise); never trust the token-requested algorithm. Flow: JWT alg = ES256 → JWKS key compatible with ES256 → signature verification.
Empty JWKS: { "keys": [] } means no verdict token can currently be cryptographically verified. Fail closed; never treat an empty key set as evidence that a token is valid. Without a signing key (local/CI), the API may also return unsigned.<eventId> and JWKS is empty; SDK helpers reject both.
Each signed token requires a kid in the JWT header: a fingerprint of the public key (first 16 base64url chars of SHA-256 over x:y). Fetch verifying keys from GET /.well-known/jwks.json.
Rotation contract: old key published → tokens signed with old kid → new key introduced → both keys published (overlap) → old key removed only after every token signed under it has expired. Token TTL is 5 minutes; keep the retiring key at least 5 minutes + 60 seconds (JWKS max-age) after the last signature under that key. A previously issued token remains verifiable for its full lifetime when this contract is followed.
HTTP caching: Cache-Control: public, max-age=60 and a strong ETag are provided. Respect Cache-Control; when ETag or Last-Modified are present, use conditional requests (If-None-Match / If-Modified-Since) instead of a hard-coded refresh interval. Unchanged sets return 304. Still refresh immediately on an unknown kid. Do not fetch on every verification, and never permanently pin a single signing key. Never disable signature verification because the JWKS endpoint is temporarily unavailable: treat jwks_unavailable and unknown_kid as fail-closed (challenge / reject), never allow.
Unsigned placeholders (local / CI only)
Without a signing key (local dev / CI), the API may return unsigned.<eventId> and JWKS is empty. SDK helpers fail closed on those tokens (signature verification cannot succeed).
In production (ENVIRONMENT=production) without CHITMARK_SIGNING_KEY, the API fails closed: non-degraded verdicts become degraded challenge instead of returning an unsigned allow. Production must configure a signing key; unsigned placeholders are for local and CI only.