Encryption Key Rotation
Encryption Key Rotation
This platform stores highly sensitive data indefinitely — including HMRC OAuth tokens and National Insurance Numbers (NINOs). Periodic rotation of the application encryption key is a critical security practice and is required under HIPAA control HIPAA-06.
This page documents the tooling, schedule, and procedure for rotating encryption keys safely.
Overview
All sensitive PII fields are encrypted at rest using the key configured in your environment. Key rotation is the process of replacing that key with a new one while re-encrypting all existing ciphertext — ensuring that a compromised old key cannot be used to decrypt data going forward.
Three categories of data are in scope:
- Bank account PII — account numbers, sort codes
- HMRC OAuth tokens — access and refresh tokens
- Org DB connection strings — per-organisation database credentials
Rotation CLI Tool
The script src/scripts/rotate-encryption-key.ts performs a full key rotation in a single database transaction.
Usage
npx ts-node src/scripts/rotate-encryption-key.ts \
--old-key "<current-encryption-key>" \
--new-key "<new-encryption-key>"
What the Script Does
- Reads every record containing an encrypted field from the database.
- Decrypts each field using the
--old-key. - Re-encrypts each field using the
--new-key. - Writes all updated records back inside a single atomic transaction — if any step fails, no changes are committed.
- Logs per-record progress to stdout and reports any individual failures without aborting the entire run.
Arguments
| Argument | Required | Description |
|---|---|---|
--old-key | Yes | The current encryption key (base64 or hex encoded) |
--new-key | Yes | The replacement encryption key |
Safety Properties
- Transactional — the database is never left in a partially-rotated state.
- Non-destructive — the old key value is not written anywhere; it is only held in memory for the duration of the script.
- Failure isolation — individual record failures are logged and counted but do not roll back the entire transaction unless they propagate to the transaction boundary.
Automated Rotation Reminder
An Inngest cron function monitors the date of the last successful key rotation (stored in the application config table) and alerts operators when the key has not been rotated in more than 90 days.
Alert Behaviour
- The cron runs on a daily schedule.
- If
now - last_rotation_date > 90 days, an alert is dispatched to configured operator notification channels. - The
last_rotation_dateconfig entry must be updated after each successful rotation (see Post-Rotation Steps below).
Rotation Procedure
Follow these steps in order every time you rotate the encryption key.
1. Generate a New Key
Generate a cryptographically secure random key. Example using Node.js:
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
Store the new key securely (e.g. in your secrets manager) before proceeding.
2. Run the Rotation Script
npx ts-node src/scripts/rotate-encryption-key.ts \
--old-key "$OLD_ENCRYPTION_KEY" \
--new-key "$NEW_ENCRYPTION_KEY"
Review the log output. Confirm:
- Total records processed matches the expected count.
- Zero failures reported.
3. Update the Environment Variable
Replace the value of ENCRYPTION_KEY (or your configured key variable) with the new key in your deployment environment before the next deploy.
⚠️ Do not deploy the new key until the rotation script has completed successfully. Deploying early will cause decryption failures for any records not yet re-encrypted.
4. Deploy
Deploy the application with the updated ENCRYPTION_KEY. The application will now use the new key for all encryption and decryption operations.
5. Post-Rotation Steps
- Update the
last_rotation_dateentry in the config table to today's date. This resets the 90-day Inngest alert timer. - Revoke / securely delete the old key from your secrets manager.
- Record the rotation in your security audit log.
Rotation Schedule
| Trigger | Action |
|---|---|
| Every 90 days (recommended) | Proactive scheduled rotation |
| Suspected key compromise | Immediate emergency rotation |
| Staff offboarding (key access) | Rotation as part of offboarding checklist |
| Inngest 90-day alert fires | Rotate within 7 days |
Background: Encrypted Fields
The following one-off migration scripts were used to encrypt data during initial setup. They are not used for ongoing rotation — use the CLI tool above instead.
| Script | Scope |
|---|---|
encrypt-bank-account-pii.ts | Bank account numbers and sort codes |
encrypt-oauth-tokens.ts | HMRC OAuth access and refresh tokens |
encrypt-org-db-connection-strings.ts | Per-org database connection strings |
Compliance
This procedure satisfies HIPAA control HIPAA-06 (Encryption Key Management). A copy of this procedure is also maintained at docs/KEY-ROTATION.md in the repository.
Troubleshooting
Script exits with decryption errors
The most common cause is providing an incorrect --old-key. Verify the value matches the currently deployed ENCRYPTION_KEY exactly, including encoding (base64 vs hex).
last_rotation_date not found in config table
On first use, insert a row manually:
INSERT INTO config (key, value) VALUES ('last_rotation_date', NOW()::text);
Subsequent rotations update this row automatically via the post-rotation step.
Inngest alert keeps firing after rotation
Confirm the last_rotation_date config table entry was updated after the last rotation run. The Inngest function reads this value directly.