Skip to content

Per-agent JWKS

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.json to 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 what JwksService.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 + framing bytes.
  • 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.

  • 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 iss and resolve a per-agent URL instead.

For agent JWT verification (the high-volume path):

  1. 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 kid prefix into multiple files (/jwks/{shard}.json).
  2. Per-agent only - agent keys are never aggregated (chosen). /agent/:id/jwks.json is the sole agent-key resolution path. The issuer() method on JwksService is removed.
  3. 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.

Aggregate with cachingPer-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 loadfull enumeration on cache miss; rotation invalidates everythingsingle indexed row readnone
Verifier overheaddownloads whole set to find one keydownloads exactly the key it needsmisleads verifiers into thinking the issuer has no agent keys
Standard-library compatibilityworks out of the boxrequires per-agent URL resolution from issendpoint exists but is a lie
Code complexitynew cache/invalidation/sharding layerdelete agent-aggregation code + spec noteminimal but actively wrong
Conflicts with federation use of same URLyes - same URL must serve two unrelated setsno - agent JWKS lives at a different URLyes - same problem
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 migrationnoneevery existing peer reconfigures
Standard-library compatibility for the federation flowbest (default issuer JWKS location)requires explicit URL
URL-purpose clarityrequires precise spec wording - semantics now narrower than the name implieseach URL has one obvious purpose

We adopt option 2 + option A. Two distinct key sets, two distinct URLs, with mutually exclusive content:

  • /agent/:id/jwks.json is the canonical resolution path for agent public keys. There is no aggregate-over-all-agents endpoint; JwksService.issuer() is removed.
  • /.well-known/jwks.json is 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:

  1. Read iss from the JWT payload. iss already encodes the agent identifier (auth.ts enforces iss === sub === agent_id).
  2. Resolve the agent’s JWKS URL - either from the DID document at /.well-known/did.json (which lists per-agent verificationMethod entries) or by deterministic construction ({origin}/agent/{iss}/jwks.json).
  3. Fetch the per-agent JWK Set, select the JWK whose kid matches 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.

  • 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.
  • Diverges from the default behaviour of generic OIDC/JWT client libraries that fetch a single issuer JWKS and look up every kid there. Adopters integrating with citizenry must implement per-agent URL resolution from the iss claim.
  • /.well-known/jwks.json has 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_key table or rotation flow). Until it is, /.well-known/jwks.json continues 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.
  • The verificationMethod array in the issuer DID document (/.well-known/did.json) remains the discovery anchor; it lists per-agent DID URLs rather than embedding keys.
  1. Delete the issuer() method from JwksService in packages/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).
  2. Keep the GET /.well-known/jwks.json route in packages/identity/src/router/index.ts returning { "keys": [] } as today, with a code comment explaining it is the federation/instance JWKS slot reserved by RFC-0001 - not an agent aggregate.
  3. In packages/spec/identity/public.tsp, rewrite the IssuerJwks interface description and @summary to make the narrowed semantics explicit. Per-agent JWKS description gains the resolution contract from “Decision” above.
  4. Update packages/spec/identity/main.tsp so the apex-zone bullet describes /.well-known/jwks.json as the instance/federation JWKS slot.
  5. Regenerate OpenAPI (pnpm --filter @citizenry/spec build or equivalent).
  6. Update docs/deploy.md and add a short note to docs/reference/rfcs/0001-federation-peers.md stating that /.well-known/jwks.json intentionally does not contain agent keys.
  • 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.json from its own service, not from agent_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.