Encryption in Transit and at Rest
What each one actually protects against, and the gap between them that catches people out.
"We encrypt everything" is usually true and usually less protective than it sounds. The two kinds defend against different threats, and the most common one is covered by neither.
In transit
TLS protects data on the wire from interception and tampering. It stops nothing once the data arrives. Every attack in this course happens after decryption.
Do it everywhere, including between internal services — "the internal network is trusted" is the assumption zero trust exists to remove.
At rest
Disk and database encryption protects against physical theft and disposal: a stolen drive, a decommissioned server, a backup left in a bucket. That is a real threat, and it is a narrow one.
It does not protect against a compromised application, because your application holds the key and the database returns plaintext to it. Transparent disk encryption stops a thief with a drive; it does nothing against SQL injection.
The gap: application-level encryption
For genuinely sensitive fields — payment details, health data, national identifiers — encrypt in the application, with keys the database never sees. A database compromise then yields ciphertext.
The cost is real: you cannot index, sort or search an encrypted column normally. That is why it is for specific fields, not everything. A common compromise is a blind index — a keyed hash stored alongside, which supports exact-match lookup without revealing the value.
Key management is the hard part
Encryption is easy; key management is the engineering. Use a KMS. Use envelope encryption — a data key per record, itself encrypted by a master key — so rotating the master key does not mean re-encrypting the table. Never keep the key next to the data.
Do not invent it
Use an authenticated cipher (AES-GCM or XChaCha20-Poly1305) through a maintained library. ECB mode, a static IV, or unauthenticated CBC are the classic homemade mistakes.
Example
// What each layer actually stops
// TLS in transit → interception on the network
// Disk / DB encryption → a stolen drive, a discarded backup
// Application encryption → a database compromise, a rogue DBA, a leaked dump
// NONE of them stop → SQL injection, BOLA, a compromised app server
//
// Which is why encryption is the LAST control to add, not the first.
// Authorization is the one that prevents the incidents that actually happen.When to use it
- A decommissioned database server is disposed of safely because full-disk encryption made the drive unreadable.
- A leaked database dump contains only ciphertext for payment details, because those fields were encrypted in the application with KMS-held keys.
- A blind index lets support look up a customer by national identifier without the plaintext ever being stored or indexed.
More examples
Envelope encryption with a KMS
The encryption context is the underused feature here: it authenticates metadata, so ciphertext moved between tenants or records refuses to decrypt.
import { KMSClient, GenerateDataKeyCommand, DecryptCommand } from '@aws-sdk/client-kms';
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
const kms = new KMSClient({});
const MASTER_KEY_ID = process.env.KMS_KEY_ID;
// Envelope encryption: a fresh DATA key per record, encrypted by the MASTER key.
// Rotating the master key does not require re-encrypting every row.
export async function encryptField(plaintext, context = {}) {
const { Plaintext: dataKey, CiphertextBlob: encryptedDataKey } =
await kms.send(new GenerateDataKeyCommand({
KeyId: MASTER_KEY_ID,
KeySpec: 'AES_256',
// Encryption context is authenticated: decryption FAILS unless the same
// context is supplied, which binds the ciphertext to this record.
EncryptionContext: context,
}));
const iv = randomBytes(12); // 96-bit nonce for GCM
const cipher = createCipheriv('aes-256-gcm', dataKey, iv);
const ciphertext = Buffer.concat([
cipher.update(plaintext, 'utf8'), cipher.final(),
]);
const tag = cipher.getAuthTag(); // authentication, not optional
dataKey.fill(0); // wipe the plaintext key
return {
v: 1, // version, for future rotation
key: Buffer.from(encryptedDataKey).toString('base64'),
iv: iv.toString('base64'),
tag: tag.toString('base64'),
data: ciphertext.toString('base64'),
};
}
export async function decryptField(envelope, context = {}) {
if (envelope.v !== 1) throw new Error('unsupported envelope version');
const { Plaintext: dataKey } = await kms.send(new DecryptCommand({
CiphertextBlob: Buffer.from(envelope.key, 'base64'),
EncryptionContext: context, // must match exactly
}));
const decipher = createDecipheriv('aes-256-gcm', dataKey,
Buffer.from(envelope.iv, 'base64'));
decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'));
const plaintext = Buffer.concat([
decipher.update(Buffer.from(envelope.data, 'base64')), decipher.final(),
]).toString('utf8');
dataKey.fill(0);
return plaintext;
}
// Usage — the context binds the ciphertext to this record and tenant, so a
// row copied into another tenant simply fails to decrypt.
await db('patients').insert({
id, tenant_id: tenantId,
national_id_enc: await encryptField(nationalId, { tenantId, recordId: id }),
});
// ❌ The homemade mistakes this avoids:
// createCipheriv('aes-256-ecb', ...) no IV, identical plaintext → identical output
// a hardcoded or reused IV catastrophic with GCM
// 'aes-256-cbc' with no MAC malleable; padding oracles
// the key in an env var next to the data a dump contains both halvesSearching encrypted data with a blind index
The frequency-analysis caveat is the one people miss — a blind index on a low-cardinality column such as 'diagnosis code' leaks the distribution.
// Encrypted columns cannot be indexed or searched. A blind index gives you
// exact-match lookup without storing or exposing the plaintext.
import { createHmac } from 'crypto';
// A SEPARATE key from the encryption key, so compromising one is not both.
const BLIND_INDEX_KEY = Buffer.from(process.env.BLIND_INDEX_KEY, 'base64');
export function blindIndex(value) {
// Normalise first, or '[email protected] ' and '[email protected]' differ.
const normalised = String(value).trim().toLowerCase();
return createHmac('sha256', BLIND_INDEX_KEY).update(normalised).digest('hex');
}
// Store both
await db('patients').insert({
id,
tenant_id: tenantId,
national_id_enc: await encryptField(nationalId, { tenantId, recordId: id }),
national_id_bidx: blindIndex(nationalId), // indexed, searchable
});
// CREATE INDEX ON patients (tenant_id, national_id_bidx);
// Exact-match lookup, with no plaintext anywhere in the query
export async function findByNationalId(tenantId, nationalId) {
const row = await db('patients')
.where({ tenant_id: tenantId, national_id_bidx: blindIndex(nationalId) })
.first();
if (!row) return null;
return {
...row,
nationalId: await decryptField(row.national_id_enc,
{ tenantId, recordId: row.id }),
};
}
// ── The trade-offs, stated honestly ──────────────────────────────────
// ✅ exact-match lookup on an encrypted field
// ❌ no range queries, no sorting, no LIKE, no partial matching
// ⚠️ equal plaintexts produce equal indexes, so frequency analysis is possible
// on low-cardinality data. For a field with few distinct values (a country,
// a status), a blind index reveals the distribution. Only use it on
// high-cardinality identifiers.
// ⚠️ rotating the blind index key means recomputing every index — plan for it
// by versioning the column, exactly like the envelope version.
// For range queries on encrypted data you need order-preserving or homomorphic
// schemes, which have their own significant leakage. Usually the better answer
// is: do not encrypt that column, and protect it with authorization instead.TLS configuration that holds up to a scan
Disabling session tickets is a small hardening step worth knowing: badly rotated ticket keys undermine forward secrecy for every session that used them.
# ── Server configuration ─────────────────────────────────────────────
# nginx
ssl_protocols TLSv1.2 TLSv1.3; # 1.0 and 1.1 are deprecated
ssl_prefer_server_ciphers off; # let TLS 1.3 negotiate
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:\
ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:\
ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off; # tickets can weaken forward secrecy
ssl_stapling on; # OCSP stapling
ssl_stapling_verify on;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# ── Verify from outside ──────────────────────────────────────────────
# Protocol support
for v in tls1 tls1_1 tls1_2 tls1_3; do
echo -n "$v: "
openssl s_client -connect dfg.com:443 -$v </dev/null 2>&1 \
| grep -q "Cipher is (NONE)" && echo "refused ✅" || echo "accepted"
done
# tls1: refused ✅ tls1_1: refused ✅ tls1_2: accepted tls1_3: accepted
# Certificate expiry — alert 30 days out, not on the day
openssl s_client -connect dfg.com:443 -servername dfg.com </dev/null 2>/dev/null \
| openssl x509 -noout -dates -subject -issuer
# Full audit
docker run --rm drwetter/testssl.sh --quiet --severity HIGH https://dfg.com
# ── Internal traffic too ─────────────────────────────────────────────
# "It is on the private network" is the assumption zero trust removes.
# A service mesh gives you mTLS between every pod with rotating certificates
# and no application code — see the mTLS lessons in the auth course.
Discussion