Cryptography you actually need
Not the maths. The decisions: which primitive for which job, the parameters that matter, and the five mistakes that account for most crypto bugs in production.
01The primitive map
- Pick the right primitive for hashing, MACs, passwords, encryption, signatures and key exchange
- Explain AEAD and why unauthenticated encryption is a bug
- Use HKDF to derive keys and constant-time comparison to check MACs
- Describe envelope encryption and why KMS designs use it
- Recognise the classic mistakes: ECB, nonce reuse, fast password hashes, rolling your own
Production cryptography is a handful of decisions made correctly and then hidden behind a good library. You do not need the maths; you need to know which tool does which job, which parameters matter and how keys are looked after. Each primitive answers a different question: is this the same data (hash), did someone with the key produce it (MAC), can only key holders read it (encryption), did this specific party produce it (signature), and how do two strangers agree a key (key exchange).
| Job | Use | Not |
|---|---|---|
| Integrity / fingerprint | SHA-256, SHA-3, BLAKE3 | MD5, SHA-1 |
| Password storage | Argon2id, scrypt, bcrypt (cost tuned) | Plain SHA-256, even salted |
| Message authentication | HMAC-SHA256 | hash(secret + msg) |
| Symmetric encryption | AES-256-GCM, ChaCha20-Poly1305 (AEAD) | AES-CBC without MAC, AES-ECB |
| Signatures | Ed25519, ECDSA P-256, RSA-PSS | RSA PKCS#1 v1.5 for new systems |
| Key exchange | X25519, ECDHE | Static RSA |
| Key derivation | HKDF-SHA256 (from a key), Argon2id (from a password) | Truncating or concatenating keys by hand |
| Random | OS CSPRNG (crypto.randomBytes) | Math.random, time seeds |
Interviewers probe whether you can tell these apart under pressure: "is a hash enough to protect this webhook?" (no, no key), "is encryption enough to stop tampering?" (only if it is authenticated), "why not sign with the shared secret?" (that is a MAC; anyone with the secret can forge it). Name the property each one gives.
If you remember one rule: modern, boring, authenticated. The library should make the safe choice the easy choice. If you find yourself choosing an IV length or a padding mode, step back and use a higher-level API (libsodium, Tink, your cloud KMS).
02Hashes and MACs
A hash maps any input to a fixed-size digest; it is collision resistant (hard to find two inputs with the same digest) and preimage resistant (hard to go backwards). It has no key, so it proves nothing about who made the data. An attacker who changes a file can recompute its SHA-256. Hashes are for fingerprints, deduplication, content addressing and as building blocks.
Why MD5 and SHA-1 are on the "not" list: collisions are practical. Researchers used an MD5 collision to forge a rogue CA certificate in 2008, and the SHAttered attack produced two different PDFs with the same SHA-1 digest in 2017. If an attacker can get you to sign or allow-list one document and then swap in its twin, collision resistance is the whole game.
A MAC (message authentication code) mixes in a secret key, so only key holders can produce a valid tag. HMAC-SHA256 is the default: webhook signatures, signed cookies, CSRF tokens, internal request signing. Two mistakes recur in reviews: building a MAC as sha256(secret + message), which length-extension attacks break, and comparing tags with ===, which leaks how many leading bytes matched through timing.
import { createHmac, timingSafeEqual } from "node:crypto";
// Verify a webhook: header "X-Signature: t=1731600000,v1=<hex hmac>"
export function verifyWebhook(rawBody: Buffer, header: string, secret: Buffer, nowSec: number): boolean {
const m = /^t=(\d{10}),v1=([0-9a-f]{64})$/.exec(header);
if (!m) return false;
const ts = Number(m[1]);
if (Math.abs(nowSec - ts) > 300) return false; // 5 minute window stops replays of old events
const expected = createHmac("sha256", secret).update(m[1] + ".").update(rawBody).digest();
const given = Buffer.from(m[2], "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}- Sign the raw bytes you received, not a re-serialised JSON object; key order and whitespace change between libraries.
- Include a timestamp inside the signed data and reject old ones, or an attacker can replay a captured request forever.
timingSafeEqualthrows on unequal lengths, so check the length first (as above) rather than letting an exception leak information or crash the handler.
Randomness is a primitive too
Session IDs, API keys, reset tokens, nonces and salts are only as strong as the generator behind them. Use the operating system CSPRNG (crypto.randomBytes, crypto.getRandomValues, secrets.token_urlsafe in Python) and take at least 128 bits for anything an attacker might guess. Math.random is a fast statistical generator whose internal state can be recovered from a handful of outputs, after which every future "random" token is predictable. Password reset links built from a timestamp or a user ID plus a counter fall to the same attack: the attacker requests a reset for themselves, sees the pattern and computes the victim's link.
03Why authenticated encryption
Encryption without authentication tells you nothing about who produced the ciphertext or whether it was altered. Padding-oracle attacks against CBC, bit-flipping attacks that change role=user to role=admin in an encrypted cookie: both come from decrypting data that was never authenticated. AEAD (authenticated encryption with associated data) gives confidentiality and integrity in one call, and lets you bind unencrypted context, the associated data, into the tag.
import { createCipheriv, randomBytes } from "node:crypto";
const key = loadKeyFromKms(); // 32 bytes, never in source
const nonce = randomBytes(12); // unique per message, never reused with the same key
const cipher = createCipheriv("aes-256-gcm", key, nonce);
cipher.setAAD(Buffer.from("tenant:42")); // bind context: ciphertext cannot be replayed for tenant 43
const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag(); // 16-byte MAC; decrypt MUST verify this before using data
store(Buffer.concat([nonce, tag, ct]));Reusing a nonce with the same key leaks the XOR of plaintexts and the authentication key. With random 96-bit nonces, rotate the key well before 2^32 messages. With counters, make the counter durable across restarts, or use a misuse-resistant mode (AES-GCM-SIV) or XChaCha20-Poly1305 with its 192-bit nonce. The AEAD and nonce discipline lesson works through the limits.
04Signatures, key exchange and derivation
Symmetric keys need both sides to share a secret in advance. Public key cryptography removes that. A signature is made with a private key and checked with the public key, so anyone can verify but only one party can sign: software releases, JWTs, certificates, audit log checkpoints. Ed25519 (RFC 8032) is the default for new systems: fast, small 64-byte signatures, deterministic, so a weak or repeated per-signature random value cannot leak the private key. That exact failure, a constant ECDSA nonce, exposed Sony's PlayStation 3 signing key in 2010.
import { generateKeyPairSync, sign, verify } from "node:crypto";
const { publicKey, privateKey } = generateKeyPairSync("ed25519"); // in production: KMS or HSM
const manifest = Buffer.from(JSON.stringify({ artefact: "academy-1.42.0.tgz", sha256: digestHex }));
const sig = sign(null, manifest, privateKey); // Ed25519 takes no separate hash algorithm
const ok = verify(null, manifest, publicKey, sig);Key exchange lets two parties agree a shared secret over a public channel. TLS 1.3 uses ephemeral X25519 (RFC 7748): both sides generate a fresh key pair per connection and throw it away afterwards, which gives forward secrecy. Stealing the server's long-term key later does not decrypt recorded traffic, because that key only signed the handshake. Static RSA key exchange had no such property and is gone from TLS 1.3.
Key exchange output is not a key you should use directly. HKDF (RFC 5869) turns a shared secret or master key into as many independent keys as you need, each labelled by an info string. Use it to derive per-tenant or per-purpose keys from one root so that the encryption key and the MAC key are never the same bytes.
import { hkdfSync } from "node:crypto";
// One master key from KMS, separate keys per purpose and tenant
const encKey = Buffer.from(hkdfSync("sha256", masterKey, salt, "academy/v1/enc/tenant:42", 32));
const macKey = Buffer.from(hkdfSync("sha256", masterKey, salt, "academy/v1/mac/tenant:42", 32));A large quantum computer would break X25519, ECDSA and RSA, but not AES-256 or SHA-256. Because recorded traffic can be decrypted later, key exchange migrates first: hybrid X25519 + ML-KEM-768 is already default in major browsers. See the post-quantum migration lesson.
05Keys are the hard part
Algorithms are rarely the weak point. Keys are: where they live, who can read them, how they rotate. The pattern every cloud KMS uses is envelope encryption.
- A root key lives in an HSM or KMS and never leaves it.
- For each object or tenant, generate a data encryption key (DEK), encrypt the data with it.
- Encrypt the DEK with the root key (wrap it) and store the wrapped DEK next to the ciphertext.
- To read: send the wrapped DEK to KMS, get the DEK back, decrypt locally. KMS logs every unwrap.
- To rotate the root key: re-wrap DEKs, not re-encrypt terabytes. To revoke a tenant: delete its DEKs (crypto-shredding).
Put a key ID and algorithm identifier in every ciphertext header from day one. Without them, the first rotation or algorithm change becomes a migration crisis, because you cannot tell which key encrypted which record. The key rotation and crypto agility lesson covers formats and re-encryption strategies.
"I would not encrypt rows directly with the KMS key: too slow and too many KMS calls. I'd use envelope encryption, one DEK per tenant, wrapped by a KMS key. Rotation re-wraps, deletion is crypto-shredding, and the KMS audit log tells me every time a tenant's data was unlocked."
06Passwords, properly
Fast hashes are the wrong tool for passwords because attackers compute billions per second on GPUs. Password hashing functions are deliberately slow and memory-hard: each guess costs real memory, which GPUs and ASICs have little of per core.
Argon2id memory=64 MiB iterations=3 parallelism=1 (~50-100 ms server-side)
bcrypt cost=12 (legacy, 72-byte input limit)
Store: $argon2id$v=19$m=65536,t=3,p=1$<salt>$<hash>
Upgrade: on successful login, if params are weaker than current policy, rehash.- The encoded string carries its own parameters and salt, so you can raise the cost later without a flag day.
- Add a server-side pepper from KMS so a database dump alone is not crackable.
- Check new passwords against breach corpora (k-anonymity API) instead of enforcing symbol rules.
- Rate limit by account and by IP, and alert on distributed low-and-slow attempts.
You find `sha256(salt + password)` in a legacy service. What is the actual problem?
07The mistakes that ship
| Mistake | What breaks | Fix |
|---|---|---|
| AES-ECB | Identical blocks encrypt identically, so patterns leak | AEAD mode |
| Nonce reuse in GCM | Plaintext XOR and forgery | Random nonces under the limit, or SIV / XChaCha |
| Unauthenticated CBC | Padding oracles, bit flipping | AEAD, or encrypt-then-MAC if stuck |
| Fast hash for passwords | Offline cracking at GPU speed | Argon2id with tuned memory |
=== on MACs or tokens | Timing side channel | timingSafeEqual after a length check |
| Keys in source or env files | Every clone and CI log has them | KMS, secrets manager, workload identity |
| Home-made protocol | Subtle composition bugs nobody reviewed | TLS, Noise, libsodium boxes, Tink |
Q1Encrypt or hash a credit card number?+
Encrypt if you need it back (envelope encryption, tokenise where possible). Hash if you only need to match (HMAC with a secret key, so the hash cannot be brute forced over the 16-digit space).
Q2Why HMAC instead of hash(secret || message)?+
Length-extension attacks on Merkle-Damgard hashes (SHA-256) let an attacker append data and compute a valid tag without the secret. HMAC is designed to prevent this.
Q3What does post-quantum change for you today?+
Harvest-now-decrypt-later means key exchange matters first. Hybrid X25519 + ML-KEM is shipping in TLS; signatures (ML-DSA) matter later for long-lived roots.
Q4Why is encrypting a JWT not a substitute for signing it?+
Encryption hides claims but, unless it is authenticated, does not prove who issued them. JWE with an AEAD algorithm authenticates the content to the key holder, but anyone holding a shared key could mint one. Sign for authenticity; encrypt only if claims are sensitive.
Related lessons
- AEAD and nonce disciplineSecEng · Applied cryptography engineeringFree
- Data protection: KMS, envelope encryption, tokenisationSecEng · Cloud & Platform SecurityPro
- Post-quantum migrationSecEng · Applied cryptography engineeringFree
- Design a key management serviceHLD · Design problems: Infrastructure & dataPro