-
Classify data first, then pick the protection tier — encryption is not the answer to everything. Three distinct goals need three different tools:
| Goal | Use | NEVER |
|---|
| Verify a credential later (passwords) | slow password hash (argon2id) — one-way | encrypt; never decrypt a password |
| Protect data you must read back (PII, PHI, PAN, tokens) | AEAD encryption + KMS envelope | reversible "encoding", base64, ROT |
| Integrity/origin without secrecy | HMAC-SHA-256 / signature | "encrypt to authenticate" |
| Index/search without revealing value | HMAC-based blind index or deterministic enc | plaintext index column |
Encrypting a password is a bug, not a feature: anything reversible means an attacker (or insider) with the key gets every plaintext password.
-
Use AEAD ciphers only. Banned modes are non-negotiable. Authenticated Encryption with Associated Data gives confidentiality and tamper-detection in one primitive:
| Use this | Why |
|---|
| AES-256-GCM | hardware-accelerated (AES-NI), NIST-approved, ubiquitous KMS support |
| ChaCha20-Poly1305 | faster on CPUs without AES-NI (mobile/ARM), constant-time by design |
| AES-256-GCM-SIV / XChaCha20-Poly1305 | nonce-misuse-resistant / 192-bit nonce — prefer when you can't guarantee unique 96-bit nonces |
| Banned | Why it's broken |
|---|
| ECB | identical plaintext blocks → identical ciphertext (the "ECB penguin"); leaks structure |
| CBC/CTR without a MAC | unauthenticated → padding-oracle (CBC) & bit-flipping attacks; ciphertext is malleable |
| Raw RSA / RSA-PKCS#1v1.5 enc | use RSA-OAEP, or better ECIES/hybrid; never "RSA the whole payload" |
| DES/3DES/RC4/MD5/SHA-1 | broken/deprecated |
Don't hand-roll "AES + separate HMAC" (encrypt-then-MAC) unless you must — get the construction order wrong and you reintroduce the oracle. Use a vetted library: libsodium (crypto_aead_* / secretbox), Go crypto/cipher GCM or nacl/secretbox, Python cryptography AESGCM/ChaCha20Poly1305 (not the low-level Cipher API), Java javax.crypto GCM or Google Tink, Rust aes-gcm/chacha20poly1305 RustCrypto crates, Node crypto.createCipheriv('aes-256-gcm', …) + getAuthTag(). Tink/libsodium are the senior default — they pick safe modes and manage nonces for you.
-
Nonce/IV discipline: unique per (key, message), forever. This is the #1 way AEAD fails. GCM with a repeated nonce under the same key is catastrophic — it leaks the XOR of plaintexts and the authentication key (forgery). Rules:
- 96-bit (12-byte) nonce for GCM. Either random from a CSPRNG (
os.urandom/crypto.randomBytes/getrandom) or a monotonic counter — never both, never 0, never a timestamp, never reuse.
- Random 96-bit nonces are safe only up to ~2³² messages per key (birthday bound). High-volume? Rotate the DEK sooner, or use XChaCha20-Poly1305 (192-bit nonce) / AES-GCM-SIV which tolerate accidental reuse.
- Store the nonce alongside the ciphertext (it's not secret) — typical record =
version ‖ nonce ‖ ciphertext ‖ tag.
- Don't derive the nonce from the plaintext or a non-unique field. Don't reuse one nonce across a re-encrypt.
-
Bind ciphertext to its context with AAD (Associated Data). AEAD lets you authenticate (not encrypt) extra context — pass the row id / tenant id / column name / key version as AAD. This stops an attacker from copying a valid ciphertext from row A into row B (ciphertext substitution): decryption of B fails because the AAD no longer matches. AAD must be reconstructible at decrypt time from the record's own metadata.
-
Envelope encryption: a KMS-held KEK wraps per-record/per-tenant DEKs. Never encrypt bulk data directly with the KMS key. The pattern that scales and rotates cleanly:
1. KMS.GenerateDataKey(KeyId=KEK, KeySpec=AES_256)
→ returns { Plaintext DEK, Encrypted DEK (wrapped by KEK) }
2. Encrypt your data locally with the plaintext DEK (AES-256-GCM, fresh nonce)
3. Store: encrypted_dek ‖ key_version ‖ nonce ‖ ciphertext ‖ tag
4. ZERO the plaintext DEK from memory immediately after use
5. Decrypt: KMS.Decrypt(encrypted_dek) → plaintext DEK → local AEAD decrypt
- KEK lives in AWS KMS / GCP KMS / Azure Key Vault / Vault Transit / an HSM and never leaves it — KMS does the wrap/unwrap, your app never sees KEK bytes. DEK is short-lived in app memory, zeroed after use.
- Granularity: per-tenant or per-record DEK for crypto-shredding (delete the DEK → that data is gone). Per-row is most flexible; cache the unwrapped DEK briefly (e.g. LRU with TTL) to avoid a KMS call per row.
- Tools: AWS KMS + the AWS Encryption SDK (handles the envelope + nonce for you), GCP KMS, HashiCorp Vault Transit (
vault write transit/encrypt/... — Vault holds the key, returns ciphertext), or Tink's KmsEnvelopeAead. Prefer these over rolling your own envelope.
-
Per-field/column encryption for PII — choose deterministic vs randomized by query need. Application-layer (encrypt before the DB sees it) beats trusting only DB-native TDE, because TDE protects the disk file, not a SQL-injection or a DBA reading rows.
| Mode | Same plaintext → | Lets you | Cost |
|---|
| Randomized (fresh nonce) | different ciphertext | only decrypt-then-use | leaks nothing; default for PII |
| Deterministic (synthetic IV / SIV) | same ciphertext | equality lookup, joins, unique constraint | leaks equality (which rows share a value) |
For searchable encryption use a blind index: store HMAC-SHA256(key, normalize(value)) in a separate indexed column and query by that, keeping the value column randomized-encrypted. Don't reach for order-preserving/fully-homomorphic encryption (leaky / impractical) unless you truly understand the tradeoff. Postgres pgcrypto is fine for small cases but does application-visible keys in SQL logs — prefer encrypting in the app. Don't encrypt a column you need range-query or LIKE on without redesigning the access pattern first.
-
Passwords: hash with a memory-hard KDF, salted and parameterized — never encrypt, never plain SHA-256. Use:
| Algorithm | Params (2025 baseline) |
|---|
| argon2id (first choice) | m=19–64 MiB, t=2–3, p=1; OWASP min m=19 MiB,t=2,p=1 |
| scrypt | N=2^17, r=8, p=1 (or N=2^15 for lighter) |
| bcrypt (legacy/compat) | cost ≥ 12; pre-hash with SHA-256 + base64 if password may exceed 72 bytes (bcrypt silently truncates) |
- A per-password random salt is mandatory (the libraries generate and embed it in the encoded hash —
$argon2id$v=19$m=...). No global "pepper-as-salt".
- Pepper (optional, defense-in-depth) = a secret key not in the DB; either HMAC the password before hashing or keep it in a KMS/HSM. Store the pepper in secrets-management, never beside the hash.
- Never use fast hashes (MD5, SHA-1, SHA-256, SHA-512) bare for passwords — GPUs do billions/sec. Never encrypt passwords (reversible = breach of all of them).
- Verify in constant time (the KDF's
verify/checkpw does this); re-hash on login if cost params have since increased.
-
TLS in transit: 1.2 minimum, 1.3 preferred; modern cipher suites; mTLS for service-to-service.
- Versions: disable SSLv3/TLS 1.0/1.1 entirely. Allow TLS 1.2 + 1.3; prefer 1.3 (1-RTT, AEAD-only, forward-secret by construction).
- TLS 1.2 cipher suites (AEAD + ECDHE forward secrecy only):
ECDHE-ECDSA-AES128-GCM-SHA256, ECDHE-RSA-AES256-GCM-SHA384, ECDHE-*-CHACHA20-POLY1305. No CBC suites, no static RSA key exchange, no NULL/RC4/3DES/EXPORT. TLS 1.3 only offers AEAD suites, so the choice is made for you.
- Mozilla SSL Config "Intermediate" is the safe default; "Modern" = TLS 1.3-only. Verify with
testssl.sh or SSL Labs (target A/A+). Enable HSTS at the edge (handoff to configure-security-headers-csp).
- mTLS for internal/service-to-service: both sides present certs; pin to your CA, short-lived certs (SPIFFE/SVID, Istio, Linkerd, or a service-mesh issuer). Validate the full chain + SAN, not just "a cert was presented."
- Never disable cert verification (
verify=False, rejectUnauthorized:false, InsecureSkipVerify:true) outside a throwaway test — it silently turns TLS into plaintext-to-anyone.
-
Key rotation with versioned keys — rotate the KEK cheaply, re-wrap DEKs, lazy-re-encrypt data. Store a key_version with every ciphertext so multiple key generations coexist:
- KEK rotation (cheapest, do on schedule, e.g. annually or per policy): KMS rotates the KEK; you re-wrap each DEK (decrypt-unwrap with old, wrap with new). Bulk data is untouched — that's the whole point of envelope encryption.
- DEK rotation: generate a new DEK, re-encrypt the affected records lazily (on next write, or a background backfill) and bump
key_version. Keep old key versions readable until backfill completes, then retire.
- On compromise: rotate immediately and force re-encryption; crypto-shred by destroying a DEK to make its data permanently unrecoverable (the GDPR-erasure trick — handoff to map-privacy-data-gdpr).
- Decrypt path must dispatch on the stored
key_version; never assume "current key." Keep a registry of retired versions for audit.
-
Operational hygiene — the parts that get forgotten. Generate all keys/nonces/salts from a CSPRNG (os.urandom, crypto.randomBytes, getrandom(2), SecureRandom) — never Math.random/rand()/mt19937. Zero plaintext keys from memory after use where the language allows (Go defer wipe, Rust zeroize, libsodium sodium_memzero). Don't log plaintext, keys, or full ciphertext. Encrypt backups and replicas too (same KMS). Use constant-time comparison for MACs/tags/tokens (hmac.compare_digest, crypto.timingSafeEqual, subtle.ConstantTimeCompare) — == leaks via timing. Run a security-review over the crypto diff before shipping.
Done = sensitive data is encrypted with AEAD under unique nonces, bulk data uses KMS envelope encryption with versioned, rotatable keys and context-binding AAD, passwords are hashed with argon2id/bcrypt (never encrypted), PII fields are randomized-encrypted (deterministic/blind-index only where a query demands it), transport is TLS 1.2+/1.3 with modern suites and mTLS where needed, and all keys/nonces/salts come from a CSPRNG with constant-time tag checks — all proven by checks 1–9, with security-review run over the crypto diff.