# Rotating JWT_SECRET without locking out 2FA users ## The problem `TwoFactorService` encrypts each user's TOTP secret (`User.twoFactorSecret`) with a key derived from a passphrase: ``` encryptionKey = scryptSync(TWO_FACTOR_ENCRYPTION_KEY ?? JWT_SECRET, 'salt', 32) ``` Historically only `JWT_SECRET` was used. That means **rotating `JWT_SECRET` changes the 2FA encryption key**, and every already-stored `twoFactorSecret` becomes undecryptable. Affected users can still enter their password but every TOTP code is rejected at `POST /auth/2fa/verify` — they are locked out of their accounts, and support cannot recover it without disabling 2FA per user. This matters because the handover requires `JWT_SECRET` to be rotated. ## Rule **Set `TWO_FACTOR_ENCRYPTION_KEY` to the value `JWT_SECRET` had when the 2FA secrets were encrypted, then rotate `JWT_SECRET` freely.** Once `TWO_FACTOR_ENCRYPTION_KEY` is set, the two keys are independent and this problem cannot recur. ## Procedure 1. Before rotating, record the current `JWT_SECRET`. 2. Add to the production environment: ``` TWO_FACTOR_ENCRYPTION_KEY= ``` 3. Set the new `JWT_SECRET`. 4. Deploy and restart. Existing 2FA secrets still decrypt; all sessions issued under the old `JWT_SECRET` are invalidated (users log in again — expected). 5. Verify with a real 2FA-enabled account before announcing the change. ## Checking the blast radius first ```sql SELECT count(*) FROM "users" WHERE "twoFactorEnabled" = true; ``` If this is `0`, no user is affected: set `TWO_FACTOR_ENCRYPTION_KEY` to a fresh random value and rotate `JWT_SECRET` independently. ## Re-keying to a fresh 2FA key later There is no bulk re-encryption script. To move to a brand-new `TWO_FACTOR_ENCRYPTION_KEY` after secrets already exist, either: - write a one-off script that decrypts with the old key and re-encrypts with the new one (`encrypt`/`decrypt` in `src/auth/two-factor/two-factor.service.ts`, format `iv:authTag:ciphertext`, AES-256-GCM), or - clear 2FA for all users and have them re-enrol: ```sql UPDATE "users" SET "twoFactorEnabled" = false, "twoFactorSecret" = NULL; ``` (Disruptive — every 2FA user must re-scan their QR code.)