Per-agent JWKS
ADR-2026-0003: Per-agent JWKS
Section titled “ADR-2026-0003: Per-agent JWKS”Context and problem statement
Section titled “Context and problem statement”Citizenry exposes Ed25519 public keys through two RFC 7517 JWK Set endpoints:
GET /.well-known/jwks.json- issuer-wide. The current spec text describes it as “every active and rotated key of this citizenry-id instance,” and the builder (packages/identity/src/service/jwks.ts:32) was sketched as an aggregate over every agent’s keys.GET /agent/:id/jwks.json- single agent, returns only the active and rotated keys of that agent (typically one or two).
The issuer-wide builder is currently throw new Error('not implemented'),
with an inline note that any real implementation “will need caching /
physical split for performance.” That deferral was deliberate: at any
non-trivial agent count, an aggregate-over-all-agents response is
wasteful for both sides of the wire.
There is also a legitimate small issuer-level JWK Set in the design
- RFC-0001 federation peers fetch
https://<peer>/.well-known/jwks.jsonto verify peer-to-peer JWS handshakes (docs/reference/rfcs/0001-federation-peers.md:82, 152, 189). That use is bounded (one or two instance-level federation keys per deployment), unrelated to per-agent JWTs, and not whatJwksService.issuer()was sketched to return. The conflation of two unrelated key sets behind one URL is the root of the problem.
The shape we observe today:
- A single Ed25519 JWK is ~120 bytes when JSON-encoded. With one active
key per agent the aggregate is roughly
120 × N + framingbytes. - At 10 000 agents the JWK Set is ~1.2 MB uncached; at 1 000 000 it is ~120 MB. D1 has to enumerate every active key on every cache miss.
- Verifiers only ever need one key per JWT - the one matching
header.kid. Downloading the full set to find one key is pure overhead.
Meanwhile our own verifier path
(packages/identity/src/auth.ts:80-128)
never consults the issuer JWKS at all. It extracts iss and kid from
the token and looks up the single matching agent_key row directly.
External verifiers (salon, federation peers) can do the same against
the per-agent endpoint, which is already O(1) in agent count.
We need a documented decision so future contributors do not implement the aggregate as a “missing feature” and accidentally ship the scalability problem.
Decision drivers
Section titled “Decision drivers”- Asymmetric cost. The aggregate endpoint is expensive for the server (full enumeration over D1) and for the verifier (megabytes to find one key). Per-agent lookup is cheap for both.
- Verifier intent. JWT validation always targets one issuer and one
kid. The issuer-wide aggregate is a denormalised view that no correct verifier needs. - D1 / Workers shape. Cloudflare D1 is not optimised for full-table
reads at request time. The per-agent path already uses an indexed
lookup on
(agent_id, status); the aggregate path would need its own caching layer (KV, R2 snapshot, or Cache API) and an invalidation story tied to key rotation. - Federation. RFC 0001 federation peers verify peer-issued JWS using DIDs and per-agent JWKS, not the issuer aggregate. The aggregate has no consumer inside the system.
- Standard compatibility. OIDC/OAuth libraries default to fetching
one JWK Set per issuer. Removing the issuer endpoint diverges from
that default and must be documented in the spec so adopters know to
read
issand resolve a per-agent URL instead.
Considered options
Section titled “Considered options”For agent JWT verification (the high-volume path):
- Implement the issuer-wide aggregate with caching/sharding. Build
the JWK Set on rotation events and serve it from KV or R2; optionally
split by a
kidprefix into multiple files (/jwks/{shard}.json). - Per-agent only - agent keys are never aggregated (chosen).
/agent/:id/jwks.jsonis the sole agent-key resolution path. Theissuer()method onJwksServiceis removed. - Permanent empty stub for the aggregate. Preserves the URL for stock JWT libraries but never returns agent keys.
For the issuer-level / federation key set (the low-volume, bounded-cardinality path):
A. Keep /.well-known/jwks.json as the federation/instance JWKS
(chosen). Redefine its semantics: it carries the deployment’s own
federation-signing keys (one or two), never per-agent keys. Matches
what RFC-0001 already assumes.
B. Move federation keys to a separate URL. e.g.
/.well-known/citizenry-peer/jwks.json. Cleaner namespace but
requires changing the federation peer discovery document
(federation_jwks_url field), the federation RFC, and every adopter.
Pros and cons - agent path
Section titled “Pros and cons - agent path”| Aggregate with caching | Per-agent only (chosen) | Permanent empty stub | |
|---|---|---|---|
| Response size at 1M agents | ~120 MB even cached; sharding adds operational surface | ~120 B per request | ~30 B per request |
| D1 load | full enumeration on cache miss; rotation invalidates everything | single indexed row read | none |
| Verifier overhead | downloads whole set to find one key | downloads exactly the key it needs | misleads verifiers into thinking the issuer has no agent keys |
| Standard-library compatibility | works out of the box | requires per-agent URL resolution from iss | endpoint exists but is a lie |
| Code complexity | new cache/invalidation/sharding layer | delete agent-aggregation code + spec note | minimal but actively wrong |
| Conflicts with federation use of same URL | yes - same URL must serve two unrelated sets | no - agent JWKS lives at a different URL | yes - same problem |
Pros and cons - federation path
Section titled “Pros and cons - federation path”A. Keep /.well-known/jwks.json (chosen) | B. Move to /.well-known/citizenry-peer/jwks.json | |
|---|---|---|
| RFC-0001 wire compatibility | ✓ unchanged | ✗ requires RFC + discovery doc revision |
| Adopter migration | none | every existing peer reconfigures |
| Standard-library compatibility for the federation flow | best (default issuer JWKS location) | requires explicit URL |
| URL-purpose clarity | requires precise spec wording - semantics now narrower than the name implies | each URL has one obvious purpose |
Decision
Section titled “Decision”We adopt option 2 + option A. Two distinct key sets, two distinct URLs, with mutually exclusive content:
/agent/:id/jwks.jsonis the canonical resolution path for agent public keys. There is no aggregate-over-all-agents endpoint;JwksService.issuer()is removed./.well-known/jwks.jsonis redefined narrowly: it carries the deployment’s instance-level federation-signing keys (typically one active + one rotated), and never per-agent keys. RFC-0001’s federation flow continues to use this URL unchanged.
Resolution contract for verifiers of agent JWTs:
- Read
issfrom the JWT payload.issalready encodes the agent identifier (auth.tsenforcesiss === sub === agent_id). - Resolve the agent’s JWKS URL - either from the DID document at
/.well-known/did.json(which lists per-agentverificationMethodentries) or by deterministic construction ({origin}/agent/{iss}/jwks.json). - Fetch the per-agent JWK Set, select the JWK whose
kidmatches the token header, and verify.
Resolution contract for federation peers is unchanged from RFC-0001:
fetch {peer-origin}/.well-known/jwks.json, expect a small
instance-level set, verify the handshake JWS against it.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- O(1) cost per agent-JWT verification on both client and server, independent of the total agent count.
- No new caching, sharding, or invalidation infrastructure required for the agent path. Agent key rotation only invalidates the affected agent’s per-agent response.
- Removes the “not implemented” agent-aggregation code path that would otherwise look like unfinished work to a new contributor.
- Each well-known URL now has one clearly-bounded purpose: per-agent for agent JWTs, federation/instance for peer handshakes.
- RFC-0001 federation continues to work unchanged.
Negative / cost
Section titled “Negative / cost”- Diverges from the default behaviour of generic OIDC/JWT client
libraries that fetch a single issuer JWKS and look up every
kidthere. Adopters integrating with citizenry must implement per-agent URL resolution from theissclaim. /.well-known/jwks.jsonhas narrower-than-default semantics (federation/instance only, never agent keys). This must be spelled out in the spec, the public docs, and the federation RFC summary so no future contributor “fixes” it by mixing agent keys back in.- The instance-level federation key issuance + storage path is not yet
implemented (no
instance_keytable or rotation flow). Until it is,/.well-known/jwks.jsoncontinues to return{ "keys": [] }. RFC-0001 consumers see an empty set and cannot verify peers - that is a pre-existing gap surfaced, not introduced, by this ADR.
Neutral
Section titled “Neutral”- The
verificationMethodarray in the issuer DID document (/.well-known/did.json) remains the discovery anchor; it lists per-agent DID URLs rather than embedding keys.
Implementation
Section titled “Implementation”- Delete the
issuer()method fromJwksServiceinpackages/identity/src/service/jwks.ts. Update the surrounding doc comment to state that this service handles per-agent JWKS only; instance/federation keys are out of scope (and will get their own service when RFC-0001 federation signing is implemented). - Keep the
GET /.well-known/jwks.jsonroute inpackages/identity/src/router/index.tsreturning{ "keys": [] }as today, with a code comment explaining it is the federation/instance JWKS slot reserved by RFC-0001 - not an agent aggregate. - In
packages/spec/identity/public.tsp, rewrite theIssuerJwksinterface description and@summaryto make the narrowed semantics explicit. Per-agent JWKS description gains the resolution contract from “Decision” above. - Update
packages/spec/identity/main.tspso the apex-zone bullet describes/.well-known/jwks.jsonas the instance/federation JWKS slot. - Regenerate OpenAPI (
pnpm --filter @citizenry/spec buildor equivalent). - Update
docs/deploy.mdand add a short note todocs/reference/rfcs/0001-federation-peers.mdstating that/.well-known/jwks.jsonintentionally does not contain agent keys.
Compliance / follow-up
Section titled “Compliance / follow-up”- If a future requirement appears for an audit-time enumeration of all
active agent keys, build it as an authenticated admin endpoint
(
/_admin/agent-keys?cursor=…) with pagination, not as the public JWK Set. Public scalability and internal observability are different problems. - When the instance-level federation key issuance / rotation work
lands (separate from this ADR), it should populate
/.well-known/jwks.jsonfrom its own service, not fromagent_key. The two services must never share a backing table. - Re-evaluate if RFC 7517 grows a standard pagination or sharding extension that closes the gap with stock client libraries on the per-agent path.
References
Section titled “References”packages/identity/src/service/jwks.ts- current builder, with the deferral note
packages/identity/src/router/index.ts- current route shape
packages/identity/src/auth.ts- internal verifier already resolves keys per agent
docs/reference/rfcs/0001-federation-peers.md- federation verification path
- RFC 7517 - JSON Web Key (JWK)