Key rotation
Rotate the JWT signing keys and the encryption key at rest without interruption: JWKS grace period, two-key re-encryption, schedule and compromise response.
Two families of keys have an operable rotation procedure, both designed to run without interrupting sign-ins or invalidating tokens still in flight.
Two keys, two procedures
| Key | Role | Stored | Rotated with |
|---|---|---|---|
| OIDC signing key (RS256) | Signs access and ID tokens | Private key encrypted in the database, public key published in the JWKS | rotate-keys |
Encryption key at rest (ENCRYPTION_KEY, AES-256-GCM) | Encrypts TOTP secrets and the OIDC private keys | Environment variable, provisioned from a vault | ENCRYPTION_KEY_OLD + reencrypt-secrets |
Both commands are subcommands of the auth-service binary, invoked here through Compose.
Rotate the JWT signing key
Rotation has no impact on clients: the previous key is marked retired but stays published in the JWKS while tokens in flight expire, then gets purged.
docker compose -f compose.prod.yml -f compose.tls.yml --env-file .env.prod \
exec auth-service /usr/local/bin/auth-service rotate-keys 48One call does three things: it generates a new active key, retires the previous active key (still published), and purges any key retired for more than the grace period in hours (48 by default). Because verifiers select the public key by kid, tokens signed with the retired key keep validating until the purge.
The grace period must be at least as long as your access and ID token lifetimes (OAUTH_ACCESS_TOKEN_TTL, OAUTH_ID_TOKEN_TTL), otherwise a still-valid token could no longer be verified.
Each rotation emits an oauth.signing_key.rotated audit event (and oauth.signing_key.purged when keys are purged), visible in the audit log.
Rotate the encryption key at rest
ENCRYPTION_KEY protects non-regenerable data (TOTP secrets), so the rotation must be lossless. The mechanism keeps the old key available for decryption only, while everything is re-encrypted under the new one.
- Generate the new key:
openssl rand -base64 32. - Reconfigure and restart:
ENCRYPTION_KEYtakes the new value,ENCRYPTION_KEY_OLDtakes the previous one. Reads keep working (decryption tries the primary key, then each old key), writes now use the new key. - Re-encrypt the existing secrets under the primary key with
reencrypt-secrets(idempotent). - Rotate the signing keys so the new active signing key is encrypted with the new
ENCRYPTION_KEY: runrotate-keys. - After the grace period, purge the retired signing keys (still encrypted with the old key): run
rotate-keys 48. Then runreencrypt-secretsagain: it must report 0 re-encrypted before you go further. - Remove
ENCRYPTION_KEY_OLDfrom the configuration, restart, and destroy the old key in the vault: no data depends on it anymore.
reencrypt-secrets is invoked like rotate-keys:
docker compose -f compose.prod.yml -f compose.tls.yml --env-file .env.prod \
exec auth-service /usr/local/bin/auth-service reencrypt-secretsAs long as ENCRYPTION_KEY_OLD still holds the old key, no loss is possible: if a step is interrupted, simply run it again. The variable accepts several comma-separated keys for close successive rotations.
The re-encryption emits a crypto.secrets.reencrypted audit event.
When to rotate
- Signing key: every 30 to 90 days, through an operator cron.
- Encryption key: on the schedule your security policy sets, and immediately when a compromise is suspected.
- On compromise: rotate at once. To invalidate tokens signed with a compromised key, purge it from the JWKS with a short grace period (for example
rotate-keys 0), accepting that not-yet-expired tokens will be rejected: that is the point.
A monthly cron for the signing key:
0 3 1 * * docker compose -f /opt/obexal/compose.prod.yml -f /opt/obexal/compose.tls.yml --env-file /opt/obexal/.env.prod exec -T auth-service /usr/local/bin/auth-service rotate-keys 48 >> /var/log/obexal/rotate.log 2>&1Operational notes
- There is no in-process auto-rotation: rotations are explicit operator commands, run one invocation at a time to avoid races between replicas.
- Back up before every rotation, like before every migration.
- The stored format does not change across rotations: values remain
base64(nonce || ciphertext || tag), and GCM authentication makes trying the wrong key fail fast.