Applied Cryptography: The Pitfalls Engineers Actually Hit
A practical, code-first tour of the crypto mistakes that bite real apps, base64 mistaken for security, MD5 password hashes, ECB, nonce reuse, timing-unsafe compares, and the boring, correct defaults that avoid them.
The crypto you ship is mostly the crypto you misuse
Nobody sets out to break their own encryption. They reach for a function that *sounds* right, md5, base64, AES, wire it up, watch the test pass, and move on. The cipher is fine. The way it was used is the vulnerability. Almost every real-world crypto failure in application code is a misuse of a sound primitive, not a broken algorithm.
Who this is for
App and backend developers who store passwords, encrypt fields, sign tokens, or compare secrets, and want the *correct defaults* without a number-theory detour. This is applied crypto, not a math course. If you want the transport layer, start with [HTTPS, TLS, and encryption basics](/blog/https-tls-and-encryption-basics).
The whole article in one rule: don't invent the scheme, invent the boring usage. Pick a vetted library, pass it the parameters it expects, and resist every clever shortcut. Below are the specific shortcuts that turn into incidents.
A mental model: sealed envelope vs locked box
Hashing proves nothing was tampered with. Encryption keeps the contents secret. Encoding just changes the alphabet. They are three different jobs, and only two of them involve a key.
A tamper-evident sealed envelope: anyone can see it was opened, but you can't un-seal it backHashing / HMAC, one-way, detects tampering, no way back to the input
A locked box: only someone with the key sees inside, and they can lock it againEncryption, two-way with a key, keeps contents confidential
Writing the address in block capitals so the courier can read itEncoding (base64/hex), reversible by anyone, zero secrecy
Three operations people constantly conflate, and which job each actually does.
base64 is not security
base64 is the block-capitals address, a transport convenience, fully reversible by anyone with `atob`. If a token, secret, or PII is 'protected' only by base64, it is plaintext with extra steps.
Encoding vs hashing vs encryption
Before any code, internalize this table. Choosing the wrong column is the root cause of a startling share of crypto bugs, 'encrypting' a password (so it can be decrypted and leaked) or 'hashing' a credit card you later need to charge (so you can't).
Encoding
Hashing
Encryption
Reversible?
Yes, by anyone
No (one-way)
Yes, with the key
Uses a key?
No
No (HMAC adds one)
Yes
Goal
Safe transport / representation
Integrity & verification
Confidentiality
Examples
base64, hex, URL-encode
SHA-256, bcrypt, argon2
AES-GCM, RSA, ChaCha20
Use it for
Putting bytes in JSON/URLs
Passwords, signatures, checksums
Secrets at rest / in transit
Pick the column that matches the job, not the one that sounds cryptographic.
Quick gut-check
Do you need to get the original value back? If yes, you want encryption (or encoding, if secrecy is irrelevant). If you only ever need to *verify* a value someone re-supplies, you want hashing.
Envelope encryption: the pattern KMS pushes you toward
When you encrypt real data at scale you almost never encrypt directly with a master key. Instead you use envelope encryption: a per-payload data key encrypts the data, and a long-lived master key (held in a KMS/HSM you never see) encrypts the data key. You store the ciphertext next to the *encrypted* data key. This is how AWS KMS, GCP KMS, and Vault all expect you to operate.
Envelope encryption: the master key never touches your data, it only wraps the short-lived data key.
1
Ask KMS for a data key
GenerateDataKey returns the same key twice: once in plaintext (use it now) and once wrapped by the master key (store it).
2
Encrypt the payload locally
Use the plaintext data key with AES-GCM and a fresh random nonce. This is fast and keeps your data off the KMS wire.
3
Throw the plaintext data key away
Zero it from memory as soon as you're done. Persist only the wrapped data key alongside the ciphertext and nonce.
4
Decrypt later
Send the wrapped data key back to KMS, get the plaintext key, decrypt the payload, discard the key again.
Why bother? Key rotation becomes cheap. Rotate the master key and you only re-wrap data keys, never re-encrypt terabytes. And the master key, your crown jewel, never leaves the KMS. Full depth in key management and encryption (KMS).
Password storage: argon2id, not SHA
Passwords are never *encrypted* (that implies they can be decrypted) and never hashed with a fast, general-purpose hash. SHA-256 and MD5 are built to be fast, exactly wrong for passwords, because fast means an attacker with your dump can try billions of guesses per second on a GPU. Use a slow, memory-hard password hash: argon2id (first choice) or bcrypt. Both salt automatically, per user.
passwords.ts
typescript
import argon2 from'argon2';
// ✅ DO: argon2id, memory-hard, per-user salt baked into the output stringexportasyncfunctionhashPassword(plain: string): Promise<string> {
return argon2.hash(plain, {
type: argon2.argon2id,
memoryCost: 19456, // ~19 MiB, tune to your hardware
timeCost: 2,
parallelism: 1,
});
// returns: $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>
}
exportasyncfunctionverifyPassword(hash: string, plain: string) {
// argon2.verify is constant-time and reads params from the hash stringreturn argon2.verify(hash, plain);
}
DO-NOT-SHIP.ts
typescript
import crypto from'node:crypto';
// ❌ DON'T: fast hash, no per-user salt, no work factor.// Crackable at billions/sec; identical passwords collide to the same digest.functionbadHash(plain: string) {
return crypto.createHash('sha256').update(plain).digest('hex');
}
// ❌ DON'T: MD5 is broken for collisions AND trivially fast. Never.// ❌ DON'T: a single global 'pepper' as your only defense.// ❌ DON'T: encrypt passwords so you can 'email them back', that's a leak vector.
Per-user salt is non-negotiable
A unique random salt per password means two users with the same password get different hashes, and precomputed rainbow tables are useless. argon2 and bcrypt generate and embed the salt for you, you don't manage it separately.
Symmetric encryption: AES-GCM, never ECB
For encrypting data with a key you hold, use authenticated encryption, AES-GCM or ChaCha20-Poly1305. 'Authenticated' means the ciphertext carries a tag that detects tampering; decrypt fails loudly if a single bit changed. Plain modes like ECB and unauthenticated CBC give you confidentiality at best and a forgery hole at worst.
ECB is the canonical disaster: it encrypts each block independently, so identical plaintext blocks produce identical ciphertext blocks. Encrypt a bitmap with ECB and you can still *see the picture* in the ciphertext. The pattern leaks straight through.
encrypt.ts
typescript
import crypto from'node:crypto';
// ✅ DO: AES-256-GCM with a FRESH 12-byte random nonce every time.exportfunctionencrypt(plaintext: Buffer, key: Buffer) {
const nonce = crypto.randomBytes(12); // unique per message, never reusedconst cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag(); // integrity/authentication tagreturn { nonce, ciphertext, tag };
}
exportfunctiondecrypt(
{ nonce, ciphertext, tag }: { nonce: Buffer; ciphertext: Buffer; tag: Buffer },
key: Buffer,
) {
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
decipher.setAuthTag(tag); // throws on tamper, that's the pointreturn Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}
DO-NOT-SHIP.ts
typescript
import crypto from'node:crypto';
// ❌ DON'T: ECB leaks structure, identical blocks -> identical ciphertext.const c = crypto.createCipheriv('aes-256-ecb', key, null);
// ❌ DON'T: a fixed/zero nonce. Reusing a nonce with the SAME key under GCM// is catastrophic: it leaks the XOR of plaintexts and can expose the auth key,// letting an attacker forge messages.const nonce = Buffer.alloc(12, 0); // every message uses the same nonce, broken
Nonce reuse breaks GCM
A GCM nonce must be unique for the lifetime of the key, never random-but-fixed, never a counter that resets. Reuse the same (key, nonce) pair twice and the mode's security collapses: plaintext XOR leaks and forgery becomes possible. Generate a fresh `crypto.randomBytes(12)` per message and store it next to the ciphertext.
Asymmetric: when you don't share a key
Symmetric crypto needs both sides to already hold the same secret key. Asymmetric crypto (RSA, elliptic-curve) solves the bootstrap problem with a keypair: a public key anyone can hold to encrypt-to-you or verify your signatures, and a private key only you hold to decrypt or sign. It's slower, so in practice it's used to exchange or wrap a symmetric key, then the bulk data goes through AES.
Need
Use
Why
Encrypt lots of data you'll read back
Symmetric (AES-GCM)
Fast, authenticated, one shared key
Two parties who never shared a secret
Asymmetric (ECDH / RSA)
Public key bootstraps a session
Prove who sent something
Signatures (Ed25519 / RSA)
Private-key signature anyone can verify
Verify integrity with a shared secret
HMAC
Fast, symmetric, tamper-evident
Reach for the right family for the job.
HMAC and constant-time comparison
When two services share a secret and you need to prove a message wasn't altered, webhook payloads, signed cookies, API request signing, use HMAC. It's a keyed hash: only holders of the secret can produce a valid tag. The catch is in how you *compare* the tag.
A naive === on strings short-circuits at the first differing byte. The time it takes to return false leaks *how many leading bytes matched*, a timing attack lets an attacker recover a valid signature byte by byte. Always compare secrets and MACs in constant time.
verify-webhook.ts
typescript
import crypto from'node:crypto';
exportfunctionverifyWebhook(body: Buffer, header: string, secret: string) {
const expected = crypto
.createHmac('sha256', secret)
.update(body)
.digest();
const provided = Buffer.from(header, 'hex');
// ✅ DO: lengths must match first (timingSafeEqual throws on mismatch),// then constant-time compare so we never short-circuit on the first byte.if (provided.length !== expected.length) returnfalse;
return crypto.timingSafeEqual(provided, expected);
}
// ❌ DON'T: `header === expected.toString('hex')`// String === returns early on the first mismatch, leaks match length via timing.
Where this bites
Anywhere you compare a user-supplied secret to a known one: API keys, password-reset tokens, signed cookie MACs, webhook signatures. Use `crypto.timingSafeEqual` (Node), `hmac.compare_digest` (Python), or `subtle.ConstantTimeCompare` (Go).
Randomness: CSPRNG, never Math.random
Tokens, salts, nonces, session IDs, password-reset links, all of these must be unpredictable. Math.random() is a fast pseudo-random generator optimized for statistical spread, not unpredictability; its internal state can be reconstructed from a few outputs. Use a cryptographically secure RNG (CSPRNG) for anything security-relevant.
tokens.ts
typescript
import crypto from'node:crypto';
// ✅ DO: CSPRNG, unpredictable, URL-safe tokenexportfunctionnewToken() {
return crypto.randomBytes(32).toString('base64url'); // 256 bits of entropy
}
// ✅ DO: bounded random int without modulo biasexportfunctionrandomInt(maxExclusive: number) {
return crypto.randomInt(0, maxExclusive);
}
// ❌ DON'T: predictable, reconstructable, low-entropyconst bad = Math.random().toString(36).slice(2); // never for tokens/secrets
It's the same trap in every language
Use `crypto.randomBytes` (Node), `secrets` (Python, not `random`), `crypto/rand` (Go, not `math/rand`). The 'easy' random module is the wrong one for security every time.
Don't roll your own crypto
The single highest-leverage rule: use a vetted, maintained library and its high-level API. Hand-rolled constructions, your own padding, your own 'encrypt-then-base64-then-XOR' scheme, your own signature check, fail in ways that pass every functional test and only surface when an attacker probes them. The hard part of crypto isn't the algorithm; it's the thousand correct decisions around it (mode, nonce, padding, comparison, key handling) that a good library has already made.
This includes 'just tweaking' a primitive
Inventing a nonce scheme, truncating an HMAC to 'save space', or combining two ciphers because one feels weak, all count as rolling your own. If a misuse-resistant library API exists (libsodium / `crypto.subtle` / your KMS SDK), use it and pass the defaults.
Prefer high-level APIs: libsodium (secretbox, crypto_box), Web Crypto subtle, your cloud KMS SDK. They make the safe path the default path.
Let the library own salts, nonces, and tags, don't reconstruct them by hand.
Don't write your own JWT/cookie signing, use a maintained library and verify the algorithm, never trust the token's alg header.
Common mistakes that cost hours (or breaches)
Treating base64 as encryption. It's encoding, reversible by anyone. Secrecy requires a key.
Hashing passwords with MD5/SHA. Too fast, no work factor. Use argon2id or bcrypt with a per-user salt.
Encrypting passwords instead of hashing them. If you can decrypt it, so can an attacker who gets the key.
AES in ECB mode. Leaks plaintext structure block-for-block. Use AES-GCM.
Reusing a GCM nonce. Catastrophic for the same key, leaks plaintext and enables forgery. Fresh nonce per message.
Comparing secrets with `===`. Timing leaks the match length. Use a constant-time compare.
`Math.random()` for tokens/salts/nonces. Predictable. Use a CSPRNG.
Skipping the auth tag check (unauthenticated CBC, or ignoring GCM's tag). No tampering detection.
Hardcoding keys in source or env you never rotate. Use a KMS and envelope encryption; see secrets management.
Rolling your own scheme because the library felt like overkill. It wasn't.
Takeaways
The whole article in ten lines
Encoding ≠ hashing ≠ encryption. base64 is encoding, zero secrecy.
Passwords: argon2id (or bcrypt) with a per-user salt. Never MD5/SHA, never 'encrypt'.
Symmetric data: AES-GCM (authenticated). Never ECB, never unauthenticated CBC.
Generate a fresh random nonce per message; reusing a GCM nonce breaks everything.
Integrity with a shared secret: HMAC.
Compare secrets and MACs in constant time, `crypto.timingSafeEqual`, not `===`.
Randomness for security: a CSPRNG (`crypto.randomBytes`), never `Math.random`.
At scale, encrypt with envelope encryption so key rotation is cheap and the master key stays in KMS.
Asymmetric bootstraps a shared key; bulk data still rides AES.
Don't roll your own crypto, use a vetted library's high-level API and pass its defaults.
Where to go next
You now have the application-layer defaults. Two layers bracket this: the transport that protects data in motion, and the infrastructure that holds your keys and secrets.
secrets management, keeping the keys and credentials this article depends on out of your code.
Practice the boundary controls in the networking lab and the bash lab for handling secrets safely on the command line.
Want to go deeper?
This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.