Symmetric encryption

Use authenticated encryption

Choose a high-level AEAD construction so ciphertext confidentiality and integrity are verified together before plaintext is accepted.

Encryption without authentication lets an attacker change your data without reading it. That sounds impossible, but flipping bits in ciphertext flips bits in the plaintext that comes out. The attacker does not need to know what the plaintext was.

Say we encrypt an account role but never authenticate the ciphertext. A controlled bit change turns the decrypted value into something else, or produces a different error the attacker can learn from. The data stayed confidential the whole time. It just stopped being trustworthy.

Authenticated encryption solves both problems in one construction. You will see it called AEAD, authenticated encryption with associated data. It encrypts and produces an authentication tag over the ciphertext. Decryption checks the tag before it releases a single byte of plaintext.

Use it by default. AES-GCM and ChaCha20-Poly1305 are the two standard choices. Reach for them through a high-level library API, and do not pick raw cipher modes yourself.

AES-GCM in Node

Let’s encrypt a short string. We need a 32-byte key and a 12-byte nonce that is never reused with the same key:

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

const key = randomBytes(32)          // 256-bit key
const nonce = randomBytes(12)        // 96-bit nonce, unique per encryption

const cipher = createCipheriv('aes-256-gcm', key, nonce)
const ciphertext = Buffer.concat([cipher.update('role=admin'), cipher.final()])
const tag = cipher.getAuthTag()      // 16 bytes

Store the nonce, the ciphertext, and the tag together. None of them is secret. Only the key is.

Decryption verifies before it trusts anything:

import { createDecipheriv } from 'node:crypto'

const decipher = createDecipheriv('aes-256-gcm', key, nonce)
decipher.setAuthTag(tag)
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()])
// throws "Unsupported state or unable to authenticate data"
// if ciphertext, tag, or nonce was modified

Flip one byte of the ciphertext or the tag and decipher.final() throws. That throw is the security feature. Do not catch it and carry on.

Fail closed, and fail the same way every time

When authentication fails, decryption must fail closed. No partial plaintext, no best-effort output. With stream-style APIs, be careful: update() can hand you bytes before final() runs the tag check. Do not act on any decrypted output until final() has passed.

Treat every authentication failure the same way. One generic error, one log path. No fallback to a weaker mode, no retry with an older code path. If a wrong tag and a wrong nonce produce different errors, an attacker can tell them apart, and distinguishable failures are how a small crack becomes an oracle.

I keep the whole thing in one small module: an encrypt() that returns a versioned object with nonce, ciphertext, and tag, and a decrypt() that throws one error type. Everything else in the app calls those two functions.

Try this on your own: encrypt a short account record with an AEAD API and store the complete versioned output. Decrypt it successfully. Then flip one byte in the ciphertext, and separately one byte in the tag, and confirm both cases return no plaintext and produce the same public error.

Lesson completed