Back to Blog
Security16 min readJun 2026

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.

CryptographyEncryptionHashingAES
SB

Sri Balaji

Founder

On this page

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).

EncodingHashingEncryption
Reversible?Yes, by anyoneNo (one-way)Yes, with the key
Uses a key?NoNo (HMAC adds one)Yes
GoalSafe transport / representationIntegrity & verificationConfidentiality
Examplesbase64, hex, URL-encodeSHA-256, bcrypt, argon2AES-GCM, RSA, ChaCha20
Use it forPutting bytes in JSON/URLsPasswords, signatures, checksumsSecrets 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.

GenerateDataKeywraps data keyplaintext keyciphertextwrapped key
Application

needs to encrypt a record

KMS / HSM

holds master key (CMK)

Data key

plaintext + wrapped copy

AES-GCM encrypt

data key + fresh nonce

Datastore

ciphertext + wrapped key + nonce

Envelope encryption: the master key never touches your data, it only wraps the short-lived data key.

  1. 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. 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. 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. 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 string
export async function hashPassword(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>
}

export async function verifyPassword(hash: string, plain: string) {
  // argon2.verify is constant-time and reads params from the hash string
  return 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.
function badHash(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.
export function encrypt(plaintext: Buffer, key: Buffer) {
  const nonce = crypto.randomBytes(12); // unique per message, never reused
  const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
  const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
  const tag = cipher.getAuthTag(); // integrity/authentication tag
  return { nonce, ciphertext, tag };
}

export function decrypt(
  { 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 point
  return 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.

NeedUseWhy
Encrypt lots of data you'll read backSymmetric (AES-GCM)Fast, authenticated, one shared key
Two parties who never shared a secretAsymmetric (ECDH / RSA)Public key bootstraps a session
Prove who sent somethingSignatures (Ed25519 / RSA)Private-key signature anyone can verify
Verify integrity with a shared secretHMACFast, 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';

export function verifyWebhook(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) return false;
  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 token
export function newToken() {
  return crypto.randomBytes(32).toString('base64url'); // 256 bits of entropy
}

// ✅ DO: bounded random int without modulo bias
export function randomInt(maxExclusive: number) {
  return crypto.randomInt(0, maxExclusive);
}

// ❌ DON'T: predictable, reconstructable, low-entropy
const 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)

  1. Treating base64 as encryption. It's encoding, reversible by anyone. Secrecy requires a key.
  2. Hashing passwords with MD5/SHA. Too fast, no work factor. Use argon2id or bcrypt with a per-user salt.
  3. Encrypting passwords instead of hashing them. If you can decrypt it, so can an attacker who gets the key.
  4. AES in ECB mode. Leaks plaintext structure block-for-block. Use AES-GCM.
  5. Reusing a GCM nonce. Catastrophic for the same key, leaks plaintext and enables forgery. Fresh nonce per message.
  6. Comparing secrets with `===`. Timing leaks the match length. Use a constant-time compare.
  7. `Math.random()` for tokens/salts/nonces. Predictable. Use a CSPRNG.
  8. Skipping the auth tag check (unauthenticated CBC, or ignoring GCM's tag). No tampering detection.
  9. Hardcoding keys in source or env you never rotate. Use a KMS and envelope encryption; see secrets management.
  10. 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.

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.