Symmetric encryption
Handle nonces and associated data
Follow the library’s nonce requirements and authenticate unencrypted context such as record IDs, versions, and content types.
A nonce is a “number used once”. It is not secret. You store it right next to the ciphertext. But reusing one can destroy the security of the whole construction.
For AES-GCM the rule is absolute: never reuse the same nonce with the same key. Two messages encrypted with the same key and nonce leak the XOR of their plaintexts. Nonce reuse can also let an attacker forge authentication tags. One repeated pair puts every message under that key at risk, not just the two involved.
How reuse happens in real systems
Nobody reuses a nonce on purpose. It happens through coordination failures. Two workers share an encryption key. Each one starts a nonce counter at zero after a deploy. Their first messages use the same nonce with the same key, and the construction is broken.
Follow the exact API contract. Prefer library-managed nonces. When you must provide one yourself, a fresh random value per encryption is the simplest safe choice:
import { randomBytes } from 'node:crypto'
const nonce = randomBytes(12) // fresh random 96-bit nonce per encryption
Random 12-byte nonces are safe for AES-GCM as long as one key does not encrypt a very large number of messages. NIST caps random-nonce use at 2^32 encryptions per key. Staying far below that is the comfortable position.
Counters avoid collisions entirely, but they need durable, coordinated state. That is exactly what the two workers above did not have. When in doubt, use random nonces and rotate keys before the volume gets large.
Associated data binds context
AEAD APIs accept a second input: associated data, often written AAD. It is not encrypted, so it stays visible. But the authentication tag covers it, so nobody can change it without breaking verification.
Use it to tie the ciphertext to its context. A record ID and a schema version work well:
const cipher = createCipheriv('aes-256-gcm', key, nonce)
cipher.setAAD(Buffer.from('user:4821:v2'))
const ciphertext = Buffer.concat([cipher.update(address), cipher.final()])
Decryption must present the same AAD, or the tag check fails.
This is what stops a valid ciphertext from being moved to another record. Say an attacker with database access copies user 4821’s encrypted address onto user 977’s row. The bytes are valid. The tag is valid. But the AAD on decryption says user:977:v2, so authentication fails instead of silently swapping one address for another.
I use the AAD for anything that answers “where does this ciphertext belong?” Record ID, table name, schema version, tenant. It costs nothing, and it closes a whole class of copy-and-paste attacks.
Try this on your own: encrypt two records with a library-managed nonce and store the nonce, record ID, and schema version with each result. Swap the record IDs at decryption time and confirm authentication fails. Then simulate two workers, or a counter restart, and show how your design makes nonce reuse impossible under the same key.
Lesson completed