ADR-001: JWT Key Management Strategy
This is a rendered copy of docs/adr/001-jwt-key-management.md, read in place from the repository — see therecords index for how to read it. If this page and the source file ever disagree, the source file is correct.
ADR-001: JWT Key Management Strategy
Status: Accepted
Date: 2026-01-15
Authors: QAuth Team
Status (2026-08-08): the “Future — JWKS Support” framing below is superseded; the JWKS endpoint and multi-key publication have shipped.
GET /.well-known/jwks.jsonis registered (apps/auth-server/src/app/routes/well-known.ts) and servesfastify.jwtUtils.getJwks(). The key set is genuinely multi-entry (libs/fastify/plugins/jwt/src/lib/fastify-plugin-jwt.ts): the active Ed25519 key, an optional RS256 key (#309), ML-DSAAKPentries (#246), and retired Ed25519 keys published under their ownkidso in-flight tokens keep verifying across a rotation (#248 F9).What is still accurate:
apps/auth-server/src/app/app.tsdoes not currently pass retired keys when it constructs the JWT plugin, so a deployed server publishes a single EdDSA key and the “users must re-login on rotation” consequence below still holds in practice. The distinction matters — the JWKS machinery is built, its retired-key configuration is not yet wired at the app layer. Read the “Future” headings below as “not yet wired”, not “not yet built”.Status (2026-10-09): ADR-019 supersedes this record’s production key model.
- This record injects keys as environment variables or files on every instance. ADR-019 replaces that model for production.
- Under ADR-019, each realm holds one key per purpose. Keys are stored envelope-encrypted in Postgres.
- The key-encryption key comes from a pluggable provider. Environment variables or a file remain the default source.
- Per-realm keys follow from ADR-019’s rule that each realm has its own host and its own issuer.
- Rotation becomes automatic and overlapping. The next key is pre-published in JWKS, and the old key stays for verification.
- ADR-019 decides to close the retired-key wiring gap described above. Until that change lands, the note above still describes a deployed server.
Context
QAuth uses EdDSA (Ed25519) asymmetric keys for JWT signing and verification. We need a strategy for:
- Key generation and storage across environments
- Key injection in CI/CD pipelines
- Key rotation without breaking user sessions
- Balancing automation ease with user experience
Technical Background
- JWT tokens are stateless - signed with private key, verified with public key
- If keys change, all previously issued tokens become invalid
- Users must re-authenticate when keys are rotated (with single-key setup)
Decision
Per-Environment Key Management
| Environment | Key Storage | Rotation Policy |
|---|---|---|
| Local Development | .env file (gitignored) | Developer discretion |
| CI Tests | Generated per run | N/A (ephemeral) |
| Staging | GitHub Secrets | Freely (testers re-login) |
| Production | Secrets Manager (Vault/AWS/GCP) | Planned, with notice |
Key Principles
-
Keys are injected at runtime, never baked into images
- Docker images contain no secrets
- Keys passed via environment variables at container start
-
Keys persist across deployments
- Same keys used until explicit rotation
- Deployments don’t invalidate user sessions
-
Different keys per environment
- Dev, staging, and production use separate key pairs
- Compromise in one environment doesn’t affect others
-
Keys are generated once, stored securely
- Not auto-generated on each startup
- Manually generated and stored in secrets manager
CI/CD Implementation
# GitHub Actions examplejobs: deploy: environment: production steps: - name: Deploy env: JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }} JWT_PUBLIC_KEY: ${{ secrets.JWT_PUBLIC_KEY }} run: | # Keys injected at runtime docker run -e JWT_PRIVATE_KEY -e JWT_PUBLIC_KEY qauth-auth-serverKey Generation
# Generate Ed25519 key pairopenssl genpkey -algorithm Ed25519 -out private.pemopenssl pkey -in private.pem -pubout -out public.pem
# Add to secrets manager (example: GitHub)gh secret set JWT_PRIVATE_KEY < private.pemgh secret set JWT_PUBLIC_KEY < public.pem
# Clean up local filesrm private.pem public.pemKey Rotation (Current - Single Key)
With the current single-key implementation:
- Generate new key pair
- Update secrets in secrets manager
- Deploy new version
- All users must re-authenticate (unavoidable)
- Communicate maintenance window for production
Key Rotation (Future - JWKS Support)
For zero-downtime rotation, implement JWKS (JSON Web Key Set):
// Future: Multiple keys for graceful rotationexport async function verifyAccessToken(token: string, publicKeys: KeyLike[]): Promise<JWTPayload> { for (const key of publicKeys) { try { return (await jwtVerify(token, key)).payload; } catch { continue; } } throw new JWTInvalidError('Verification failed with all keys');}With JWKS endpoint (/.well-known/jwks.json):
{ "keys": [ { "kid": "2026-01", "kty": "OKP", "crv": "Ed25519", "x": "..." }, { "kid": "2025-12", "kty": "OKP", "crv": "Ed25519", "x": "..." } ]}Rotation process:
- Add new key to JWKS (both keys valid)
- Start signing with new key
- Wait for old tokens to expire (grace period)
- Remove old key from JWKS
Consequences
Positive
- Simple setup - Single key pair per environment
- Secure - Keys never in source code or images
- Automatable - CI/CD injects keys at runtime
- Auditable - Secrets managers provide access logs
Negative
- Session loss on rotation - Users must re-login (mitigated by future JWKS)
- Manual key generation - One-time setup per environment
- Coordination required - Production rotation needs planning
Neutral
- Standard industry practice for JWT-based authentication
- Compatible with future JWKS enhancement