Symmetric encryption

Use envelope encryption

Encrypt data with a data key and protect that key with a separate key-encryption service so rotation and access can be controlled centrally.

Encrypting every record with one long-lived master key makes rotation painful. Think about what rotating that key means. Millions of records were encrypted with it. To rotate, you read and rewrite every record, while the old key stays available to everyone during the migration. Most teams look at that job and quietly never rotate.

Envelope encryption splits the job across two keys so the problem goes away.

Two keys, two jobs

You generate a fresh data-encryption key (DEK) for each object or group of records and encrypt the data with it. Then you encrypt, or “wrap”, that DEK with a key-encryption key (KEK) held by a managed KMS or vault. You store the wrapped DEK next to the ciphertext.

The KEK never leaves the key service. The plaintext DEK exists only in application memory, only for the duration of the operation:

import { randomBytes, createCipheriv } from 'node:crypto'

const dek = randomBytes(32)                    // fresh per record
const nonce = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', dek, nonce)
const ciphertext = Buffer.concat([cipher.update(document), cipher.final()])

const wrappedDek = await kms.encrypt({ keyId: 'kek-prod-v2', plaintext: dek })
// the plaintext dek is discarded after this point

A stored record then carries everything needed to decrypt it later, except the authority to do so:

{
  "v": 2,
  "kek_id": "kek-prod-v2",
  "wrapped_dek": "AQIDAHh3...",
  "nonce": "8kQzL1Xb9mPq",
  "tag": "5tR8...",
  "ciphertext": "..."
}

To read the record, the application asks the KMS to unwrap wrapped_dek, decrypts locally, and throws the DEK away again. The KMS sees one small unwrap call per read. It never sees your data.

Why this structure pays off

Rotating the KEK no longer means re-encrypting your data. You unwrap each DEK with the old KEK and rewrap it with the new one. The ciphertext bytes never move, and the plaintext is never exposed. You rewrite a 32-byte field per record instead of the record itself.

Access control moves to one place. The KMS decides who may unwrap keys, and its audit log shows every decryption request. Lose a database backup and the attacker gets wrapped keys, which are useless without the KEK.

Performance is fine too. The expensive network call to the KMS happens once per record read, on a tiny payload. The bulk encryption runs locally with AES, which is fast.

Be careful with one thing: bind each wrapped key to its record with authenticated context. Otherwise a valid wrapped DEK can be moved onto a different record without anyone noticing. The AAD from the previous lesson is the tool for that.

Try this on your own: draw one encrypted record with its ciphertext, nonce, wrapped data key, key identifier, version, and authenticated context. Simulate rotating the KEK by rewrapping the DEK and confirm the ciphertext bytes did not change. Then decrypt with the wrong record context and check that you get the expected failure.

Lesson completed