Encryption Key Operations
How ENCRYPTION_KEY is used, what "rotation" does and does not cover, and what to do when a key is lost or leaked.
This matters because the same variable protects two things with different lifecycles: encrypted item fields, which a migration can re-wrap, and TOTP two-factor seeds, which it cannot. Treating them the same is how operators lock users out of their own accounts.
The key set
| Variable | Meaning |
|---|---|
ENCRYPTION_KEY | Legacy single key. Resolved as version v0 |
ENCRYPTION_KEY_<id> | Versioned key material, e.g. ENCRYPTION_KEY_v1 |
ENCRYPTION_ACTIVE_KEY_ID | Which version encrypts new data. Defaults to the only configured key, otherwise v0 |
ENCRYPTION_KEY[_<id>]_FILE | Read the key from a file instead (Docker secrets). The direct variable wins |
Each key is base64 of 32 random bytes:
openssl rand -base64 32
Every ciphertext is stored as a versioned envelope, <keyId>:<base64(iv‖ciphertext‖tag)>, so the row records which key wrapped it. AES-256-GCM throughout.
What rotation covers
Adding ENCRYPTION_KEY_v1 and setting ENCRYPTION_ACTIVE_KEY_ID=v1 changes the key used for new writes. Existing rows keep their old keyId until something re-wraps them.
| Data | Re-wrapped by POST /api/v1/admin/encryption/envelope/migrate? |
|---|---|
Encrypted item fields (items.dek_wrapped) | Yes |
TOTP seeds (lumibase_user_totp_credentials.secret_ciphertext) | No |
The migration worker walks items only — see the imports at the top of apps/cms/src/services/envelope-migration-worker.ts. Nothing re-wraps a TOTP seed.
Never remove a key that TOTP seeds still reference.
decryptTotpSecretresolves the key from the envelope'skeyId, so droppingENCRYPTION_KEYafter rotating tov1breaks every enrollment made underv0.
Check what is still in use before retiring a key:
SELECT secret_key_id, count(*)
FROM lumibase_user_totp_credentials
GROUP BY secret_key_id;
Keep every key id that appears here configured, indefinitely.
Failure mode: a referenced key is missing
If the key an enrollment needs is not configured, the 2FA endpoints fail closed — no seed is ever read or written in plaintext — and report 409 with TFA_KEY_UNAVAILABLE, naming the key id so you know which one to restore:
POST /api/v1/auth/verify-totp -> 409 TFA_KEY_UNAVAILABLE
POST /api/v1/me/tfa/recovery-codes -> 409 TFA_KEY_UNAVAILABLE
Recovery codes keep working, because they are PBKDF2 hashes rather than KEK-wrapped:
POST /api/v1/auth/verify-totp { recoveryCode } -> 200
So an affected user signs in with a recovery code and then removes the dead factor themselves — DELETE /me/tfa accepts a recovery code in place of a TOTP code for exactly this case, and re-enrolling afterwards wraps a fresh seed under the current key:
DELETE /api/v1/me/tfa { password, recoveryCode } -> 200
POST /api/v1/me/tfa/setup -> 200
Regenerating recovery codes is deliberately not available this way: topping up codes for an enrollment that can never produce a valid TOTP code again would only extend the outage.
If the key is unrecoverable and you would rather not wait for each user to notice, an operator can clear the affected enrollments in bulk. There is no admin endpoint for this today:
-- Per user. Removes the credential and its recovery codes (FK cascade),
-- then clears the non-secret enrollment state the Studio UI reads.
DELETE FROM lumibase_user_totp_credentials WHERE user_id = $1;
UPDATE lumibase_users SET tfa = '{}'::jsonb WHERE id = $1;
Tell the affected users: their second factor is gone until they re-enroll, so the account is password-only in the meantime.
Failure mode: a key leaked
Rotation is not remediation. There is no per-user key derivation — one KEK wraps every seed, and the AAD (totp-secret|<userId>) only binds an envelope to its owner so ciphertext cannot be replayed under another user id. It is not a confidentiality boundary. Anyone holding the leaked key can decrypt every seed enrolled under it and generate valid codes indefinitely, silently, leaving nothing in the audit trail.
This is inherent to TOTP: the server has to keep the shared secret recoverable in order to verify a code. Compare the recovery codes in the same feature, which are one-way hashes and therefore not recoverable even by an operator.
Response:
- Rotate: add a new
ENCRYPTION_KEY_<id>, pointENCRYPTION_ACTIVE_KEY_IDat it. New enrollments and new item writes are protected from this point on. - Re-wrap item fields:
POST /api/v1/admin/encryption/envelope/migrate, then poll it todone. - Force TOTP re-enrollment for everyone enrolled under the leaked key — step 2 does not touch them, and step 1 does not protect them. Use the SQL above per user, and keep the old key configured until every row has moved off it.
- Treat sessions as suspect: disabling 2FA bumps
tokenVersionand revokes refresh tokens for that user, which is the intended side effect here.
Escaping the property altogether means changing the mechanism rather than the storage — WebAuthn/passkeys keep only a public key server-side, so there is nothing for a leaked KEK to unlock.
Before first enrollment
ENCRYPTION_KEY must be configured before anyone enrolls in 2FA or writes an encrypted field. Without it, POST /api/v1/me/tfa/setup returns 503 with ENCRYPTION_NOT_CONFIGURED; nothing is half-written, so enrollment works as soon as the key is in place.
Three places check for you, in order of how early they catch it:
| Where | Behaviour when the key is missing |
|---|---|
pnpm release:check | Fails before a Cloudflare deploy — ENCRYPTION_KEY is in the required-secrets list |
| CMS boot, production only | Refuses to start (REQUIRED_PRODUCTION_VARS in config/production.ts) |
| Setup wizard, every runtime | GET /setup/capabilities reports encryption.available: false and the Security step shows a notice that 2FA cannot be enrolled |
The wizard notice exists because the first two only cover production and deploys: a local, Docker-staging or Workers-preview instance boots happily without a key, and the first sign used to be a 503 the first time someone opened Settings → Security.
Set it as a real secret, never in committed config:
# Cloudflare
wrangler secret put ENCRYPTION_KEY --env production
# Docker
ENCRYPTION_KEY_FILE=/run/secrets/encryption_key