Applies to: every signed object the toll gates produce — v5 exactness
attestations, v4 grant envelopes, v3 escrow receipts, v6 bond receipts, v7
memory-export receipts, and POST /api/toll/public/attest envelopes.
| Key | Signs | kid | Lives in JWKS |
|---|---|---|---|
Rider identity key (RIDER_PRIVATE_KEY) |
Rider JWTs only — never toll receipts | RFC 7638 thumbprint | Yes (backwards compat: receipts minted before the dedicated toll signer still verify) |
Toll signer (TOLL_SIGNING_KEY) |
All toll receipts | RFC 7638 thumbprint, distinct from the Rider kid | Yes |
| Previous toll signers | Nothing (grace-period verification only) | Their own thumbprints | Yes, while in grace |
Envelope shape is unchanged: {payload, sig, kid, alg: "ES256"}.
GET /.well-known/jwks.json (Cache-Control: public, max-age=3600, stale-while-revalidate=86400):
{
"keys": [ { "kty": "EC", "crv": "P-256", "x": "…", "y": "…",
"kid": "<rfc7638-thumbprint>", "alg": "ES256", "use": "sig" } ],
"revoked": [ { "kid": "<thumbprint>", "revoked_at": "2026-10-08T13:00:00Z" } ]
}keyscarries the Rider identity key, the current toll signer, and any previous toll signers still in their rotation grace period.revokedlists kids that MUST fail closed. Revoked kids stay listed — they are never silently dropped, so a verifier can always distinguish "revoked" from "unknown".
- Fetch the JWKS. Cache it at most 1 hour (honor our
max-age=3600). A verifier that cannot fetch a fresh JWKS must refuse — never verify against a stale snapshot and call it good. - Look up the envelope's
kidin the JWKSrevokedlist first. Hit → fail closed with coderevoked_kid. - Look up the
kidinkeys. Miss → fail closed with codeunknown_kid. - Verify the ES256 signature over the canonical payload. Failure → fail
closed (
bad_signature/malformed_envelope). - Every failure mode is a named, recognizable code. There is no silent
pass: a verifier with a stale or revoked
kidfails closed, loudly.
Performed rarely, manually, with the lab's key ceremony. No automation mints or moves keys.
- Generate a new ES256 P-256 keypair offline. Export the private half as
PKCS8 PEM; keep it secret. Export the public half as a JWK
(
{"kty":"EC","crv":"P-256","x":"…","y":"…"}). - Overlap: set
TOLL_PREVIOUS_PUBLIC_JWKSto a JSON array containing the OLD signer's public JWK, and setTOLL_SIGNING_KEYto the NEW private PEM. Deploy. The JWKS now serves old + new kids — both verify. - Flip: signing already uses the new key (step 2 did it atomically with
the deploy). Confirm new receipts carry the new
kid. - Grace period: keep the old kid trusted (recommendation: 7 days, long enough for the 1-hour verifier cache to turn over many times).
- Revoke: add the old kid to
TOLL_REVOKED_KIDS("<kid>:<ISO-8601-timestamp>", comma-separated for several), and remove it fromTOLL_PREVIOUS_PUBLIC_JWKS. Deploy. Old receipts now fail closed withrevoked_kid— recognizable, never a silent pass, and the kid stays listed inrevokedpermanently.
Suspected compromise: skip the grace period. Add the kid to
TOLL_REVOKED_KIDS and deploy immediately. The revocation check runs
before key lookup, so a revoked kid fails closed even if its key is still
listed in keys.
| Variable | Format | Required |
|---|---|---|
TOLL_SIGNING_KEY |
ES256 P-256 PKCS8 PEM (private) | Yes — signing fails closed (500) without it; no fallback to the Rider key, ever |
TOLL_PREVIOUS_PUBLIC_JWKS |
JSON array of public JWKs (kty/crv/x/y) |
No — only during rotation grace |
TOLL_REVOKED_KIDS |
"kid:ISO-timestamp,kid:ISO-timestamp" |
No — empty means none revoked |
RIDER_PUBLIC_KEY |
ES256 SPKI PEM (public) | Yes — JWKS always serves the Rider key |
Malformed TOLL_REVOKED_KIDS or TOLL_PREVIOUS_PUBLIC_JWKS values throw at
startup/parse time — trust roots are never silently misread.
A verifier holding a pre-rotation JWKS snapshot, handed a receipt signed with the post-rotation key, MUST fail closed with
unknown_kid— not pass, not error ambiguously. A verifier handed a receipt whose kid is in therevokedlist MUST fail closed withrevoked_kid— even if the key is still listed inkeys.
Both behaviors are covered by selftest:toll-rotation (32 assertions) and
by the corruption sandbox (sk_sandbox_demo): submit a tampered kid and
watch the refusal codes.