← All documentation

Rotating the encryption key

Rotating the encryption key

ENCRYPTION_KEY seals every secret Coldfeet stores: DKIM private keys, downstream relay passwords, integration credentials and TOTP secrets. Replacing it naively makes all of them unreadable — signing breaks, delivery breaks, and 2FA locks everyone out, all without an obvious error.

Ciphertext therefore carries the id of the key that produced it:

enc:<key-id>:<iv>:<tag>:<data>

Old keys stay available for reads while a background sweep re-encrypts each row, so rotation is a migration rather than a cutover.

Rotating

  1. Back up first. A rotation touches every sealed column.

    sudo ./install/backup.sh --label pre-rotation
    
  2. Add the new key, keeping the old one readable. In .env.production:

    ENCRYPTION_KEY=<new 64-char hex secret>
    ENCRYPTION_KEY_ID=2
    ENCRYPTION_KEYS_OLD=1:<the previous ENCRYPTION_KEY value>
    

    Generate the new secret with openssl rand -hex 32. If the previous install never set ENCRYPTION_KEY_ID, its values are tagged legacy, so use ENCRYPTION_KEYS_OLD=legacy:<previous value>.

  3. Restart so both keys are loaded.

    docker compose -f docker-compose.prod.yml up -d --force-recreate api worker
    

    The API logs how many retired keys it loaded at startup. Everything keeps working at this point: new writes use the new key, old values still read.

  4. Check what is outstanding. Admin → Security, or:

    curl -s -H "Authorization: Bearer $TOKEN" \
      https://your-host/api/v1/admin/security/encryption-key
    
  5. Run the rotation.

    curl -s -X POST -H "Authorization: Bearer $TOKEN" \
      https://your-host/api/v1/admin/security/encryption-key/rotate
    

    The response reports how many values moved per column, and the action is written to the audit log.

  6. Confirm nothing is pending, then remove the old key:

    ENCRYPTION_KEYS_OLD=
    

    Restart once more. Keep the old secret in your password manager until the backups that contain it have aged out — restoring one of those needs it.

If a value cannot be read

The rotation report counts unreadable rows. That means a value was sealed with a key not present in ENCRYPTION_KEYS_OLD, usually because ENCRYPTION_KEY was changed at some point without rotating. Find the old secret and add it, then run the rotation again.

If the old secret is genuinely gone, the affected data must be recreated:

ColumnRecovery
domain.dkimPrivateKeyEncRegenerate DKIM for the domain and republish the DNS record
integrationConfig relay passwordRe-enter it in the domain's relay settings
user.totpSecretHave the user re-enrol 2FA
platformSetting.valueEncRe-enter the setting in Admin → Integrations