Files
backend/docs/2fa-key-rotation.md
Sathish c9b38dc6ab fix(security): resolve audit findings — secrets, env contract, migrations
Removes hardcoded fallback secrets and makes a misconfigured deploy fail
loudly instead of silently falling back to development defaults.

- Remove insecure JWT fallback secrets (messages.module, configuration)
- Remove the 'default-secret' fallback for the 2FA TOTP encryption key and
  allow a dedicated TWO_FACTOR_ENCRYPTION_KEY so rotating JWT_SECRET no
  longer locks out every 2FA user (see docs/2fa-key-rotation.md)
- Require EMAIL_API_URL; drop the hardcoded vendor email endpoint
- Drive WebSocket CORS from CORS_ORIGINS instead of origin:'*'
- Load .env before any Nest module is imported (src/load-env.ts). Decorator
  arguments evaluate at import time, so the gateway previously froze its CORS
  config to the localhost fallback even when CORS_ORIGINS was set
- Add boot-time env validation: missing required vars, weak JWT_SECRET, and
  inverted access/refresh token lifetimes now abort startup
- Enable Redis TLS certificate verification
- Require ADMIN_EMAIL/ADMIN_PASSWORD for the seed; remove the published
  default super-admin credentials and stop printing them
- Add the initial Prisma migration and stop gitignoring prisma/migrations
- Make .env.example an accurate configuration contract (admin bootstrap,
  REDIS_TLS, S3_ENDPOINT, 2FA key, Firebase path; drop the dead SMTP block)
- Add handover documentation: architecture, ER model, sequence and data-flow
  diagrams, 2FA key rotation runbook

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:03:08 +05:30

2.2 KiB

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=<the OLD JWT_SECRET value>
    
  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

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:
    UPDATE "users" SET "twoFactorEnabled" = false, "twoFactorSecret" = NULL;
    
    (Disruptive — every 2FA user must re-scan their QR code.)