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”.
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