Password Attacks & Cryptography · intermediate · ~11 min

Common cryptography mistakes

- Recognize the cryptography misuse patterns that show up most often in code review, audits, and pentests: home-rolled crypto, ECB mode, IV/nonce reuse, hardcoded keys, unauthenticated encryption, timing-unsafe comparisons, weak randomness, and JWT signing flaws. - Explain *why* each pattern is dangerous, in plain language, well enough to teach it to a teammate. - For every weak pattern, name the correct, vetted replacement (AEAD, unique nonces, CSPRNG, constant-time compare, secret vaults). - Read a small code snippet or config and spot the specific line that is the cryptographic flaw. - Write an insecure-vs-secure remediation note that a developer can act on, including how to *test* that the fix worked. - Distinguish a real finding from a false alarm (e.g. `rand()` used for a non-security shuffle is fine; `rand()` used for a token is not).

Overview

When people imagine a cryptography failure, they picture a genius cracking AES. That is almost never what happens. Modern, standard algorithms — AES, SHA-256, RSA, ECDSA — are extremely hard to break directly. The overwhelming majority of real-world cryptographic failures are misuse of good algorithms, not weaknesses in the algorithms themselves. Someone picked the wrong mode, reused a value that must be unique, pasted a key into source code, or compared two secrets with a function that leaks timing. The math was fine; the usage was broken.

This lesson builds directly on Symmetric and asymmetric encryption, your prerequisite. There you learned the building blocks: a symmetric cipher uses one shared secret key for both encryption and decryption; an asymmetric scheme uses a public/private key pair. Knowing the primitives is step one. This lesson is step two: how those primitives go wrong in practice, and how to use them safely. Together with the next lesson, Password storage: salts, slow hashes, and work factors, it forms the practical "don't shoot yourself in the foot" core of applied cryptography.

Why focus on misuse? Because misuse is what you will actually encounter. In a code review you will not be asked to break a cipher — you will be asked to look at how a cipher is called. A finding like "this AES key is hardcoded on line 42" is concrete, common, and fixable. Learning to spot these patterns, and just as importantly to recommend the correct replacement, is one of the highest-value defensive skills you can have.

A few terms used throughout:

  • Plaintext / ciphertext — the readable data and its encrypted form.
  • Key — the secret that controls encryption and decryption.
  • IV (initialization vector) / nonce ("number used once") — a per-message value that makes encryption of the same plaintext produce different ciphertext. The exact uniqueness/randomness rules depend on the mode.
  • MAC (message authentication code) — a tag that proves a message was not tampered with and came from someone who holds the key.
  • AEAD (Authenticated Encryption with Associated Data) — a mode that does encryption and integrity in one step (e.g. AES-GCM, ChaCha20-Poly1305).
  • CSPRNG — a cryptographically secure pseudo-random number generator.

Why it matters

These misuse patterns are exactly what source-code reviews, audits, and penetration tests turn up over and over: hardcoded keys, nonce reuse, timing-unsafe comparisons, and alg:none JWTs. They are not exotic. They are the bread and butter of real findings because they are easy to introduce by accident and invisible until someone looks.

The stakes are high and concrete:

  • A reused nonce in AES-GCM can let an attacker recover the authentication key and forge messages — the encryption is effectively broken even though AES is not.
  • A hardcoded key committed to a repository stays exploitable forever, even after the code changes, because git history keeps the secret. Leaked-secret scanners find these constantly.
  • A timing-unsafe MAC check can let an attacker forge a valid signature one byte at a time, purely by measuring response times.
  • A JWT that accepts alg:none lets anyone mint a token claiming to be any user, with no signature at all.

Spotting the pattern is only half the job. The part that makes a finding useful is knowing the safe replacement and how to verify it. "Use AEAD and a unique nonce" turns a vulnerability report into a fix. That is the skill this lesson teaches.

Core concepts

Each subsection below is a distinct misuse pattern. For each: what it is, why it breaks, when the rule applies (and when it does not), and a common pitfall. Inline Knowledge check prompts ask you to reason, not to recall the quiz.

1. Home-rolled ("do-it-yourself") crypto

Definition. Inventing your own cipher, hash, key-exchange, or "obfuscation" instead of using a standard, vetted construction.

Why it breaks. Cryptography is adversarial: a scheme is only trustworthy after years of expert attack. A homemade scheme has had zero. XORing data with a fixed string, "scrambling" bytes, or base64-encoding (which is encoding, not encryption — it has no key) all feel like security but provide none. Attackers reverse them in minutes.

When the rule applies. Always, for anything protecting real data. The only legitimate place to implement a primitive yourself is a learning exercise in an isolated lab — never in production.

Pitfall. Confusing encoding with encryption. Base64, URL-encoding, and hex are reversible by anyone; they keep nothing secret.

Knowledge check (explain in your own words): A developer says "I XOR every byte with the constant 0x5A, so the data is encrypted." Why is this not real encryption, and what is the effective key length an attacker must guess?

2. ECB mode

Definition. Electronic Codebook mode encrypts each fixed-size block independently with the same key.

Why it breaks. Identical plaintext blocks produce identical ciphertext blocks. Structure in the data — repeated regions, patterns — shows through the ciphertext. The classic demonstration is encrypting a bitmap image in ECB: the outline of the picture is still visible.

Plaintext blocks:   [AAAA][BBBB][AAAA][CCCC]
ECB ciphertext:     [ X ][ Y ][ X ][ Z ]   <- same input block -> same output block
                      ^---------^  pattern leaks: blocks 1 and 3 are identical

When NOT to use it. Essentially never for real data. Prefer an AEAD mode (AES-GCM, ChaCha20-Poly1305).

Pitfall. ECB is often the default in some libraries or examples, so it gets copied without thought.

3. IV / nonce reuse

Definition. An IV or nonce is a per-message value that makes encryption non-deterministic. The rule: it must be unique for every encryption under the same key, and for some modes it must also be unpredictable/random.

Why it breaks. Reuse removes the per-message uniqueness the mode relies on:

  • Stream ciphers / CTR-style modes: the same nonce produces the same keystream. XORing two ciphertexts that share a keystream cancels the keystream and leaks the XOR of the two plaintexts.
  • AES-GCM specifically: reusing a nonce under the same key can leak the GCM authentication subkey, letting an attacker forge authenticated messages. This is catastrophic — far worse than just a confidentiality leak.
Safe:    msg1 -> nonce N1 -> ciphertext C1
         msg2 -> nonce N2 -> ciphertext C2     (N1 != N2)

Broken:  msg1 -> nonce N  -> C1
         msg2 -> nonce N  -> C2     (same N, same key)
         C1 XOR C2  ==  P1 XOR P2   <- plaintext relationship leaks; GCM auth key at risk

When the rule applies. Every encryption operation under a given key. A nonce is not a secret, but it must be unique. Generate it from a CSPRNG or a properly managed counter that can never repeat (watch out for counter resets after a crash or VM restore).

Pitfall. Hardcoding the IV to a fixed value (e.g. all zeros) "to keep decryption simple." That is the worst case: every message shares one nonce.

Knowledge check (find-the-bug): A service encrypts each user record with AES-GCM using nonce = sha256(user_id). The user_id never changes. What property is violated, and what can an attacker do with two records belonging to the same user?

4. Hardcoded keys and secrets

Definition. Cryptographic keys, API tokens, or passwords written literally into source code or into config files committed to version control.

Why it breaks. Source code spreads: it is shared, forked, backed up, and indexed. Once a secret is in git history it is effectively public forever, even after you delete the line — the old commit still contains it. Anyone with read access (or anyone who finds a leaked repo) gets the key.

When the rule applies. Always. Secrets belong in a secrets manager / vault, in environment variables injected at deploy time, or in a dedicated key-management service — never in the codebase. In examples and labs, use a clearly fake placeholder like API_KEY=<development-placeholder>.

Pitfall. Assuming a private repository is safe. Repos get cloned, leaked, made public by mistake, or accessed by departed employees. Treat every committed secret as compromised and rotate it.

5. Unauthenticated encryption (no integrity)

Definition. Encrypting without also verifying integrity — using a plain mode like CBC or CTR with no MAC, instead of an AEAD mode.

Why it breaks. Encryption hides data but, on its own, does not detect tampering. An attacker can flip bits in the ciphertext and the decryptor will happily produce altered plaintext. This enables padding-oracle attacks (where error behavior leaks plaintext) and silent message forgery.

The fix: AEAD. AEAD combines confidentiality and integrity in one operation and rejects any tampered ciphertext before producing plaintext. The "Associated Data" part lets you bind unencrypted context (like a header or message ID) to the ciphertext so it cannot be moved or replayed.

Plain encryption:   ciphertext -> [decrypt] -> plaintext        (tampering undetected)
AEAD:               ciphertext + tag -> [verify tag] --fail--> reject
                                                     --ok----> plaintext

When NOT to skip it. Never skip integrity. "It's just encrypted, who cares about tampering" is the exact reasoning that leads to padding-oracle breaches.

Pitfall. Encrypt-then-... in the wrong order, or rolling your own "encrypt + separate MAC" and getting the ordering or the compare wrong. Use a single AEAD primitive and let the library handle it.

6. Non-constant-time comparison

Definition. Comparing two secrets — a MAC, a token, a password hash — with an operation that returns as soon as it finds a mismatching byte (==, strcmp, memcmp).

Why it breaks. Early-exit comparison takes slightly longer the more leading bytes match. An attacker who can measure response time can guess the secret one byte at a time: try all values for byte 0, keep the one that is measurably slower (it matched), move to byte 1, and so on. This turns an infeasible brute force over the whole value into a feasible per-byte search.

guess vs secret, early-exit compare:
  AXXXXXXX  -> mismatch at byte 0 -> returns fast
  SXXXXXXX  -> matches byte 0, mismatch at byte 1 -> returns slightly SLOWER
                  ^ timing difference reveals "first byte is S"
Repeat per position -> recover the whole secret without ever "guessing" it whole.

The fix. Use a constant-time comparison that always examines every byte and combines the results, so the time does not depend on where the first mismatch is (e.g. accumulate differences with OR/XOR and check at the end; many libraries provide hmac.compare_digest, CRYPTO_memcmp, timingsafe_bcmp). The platform has a C exercise, constant-time-compare, for exactly this.

Knowledge check (predict the behavior): Two functions both correctly return "equal/not equal" for any input. One uses memcmp; one accumulates diff |= a[i] ^ b[i] over all bytes. For correctness they are identical. For security they are not. Why?

7. Weak randomness

Definition. Using a general-purpose, non-cryptographic RNG (like C's rand(), or many languages' default Math.random) to generate keys, IVs, tokens, session IDs, or password-reset codes.

Why it breaks. General-purpose RNGs are built for speed and statistical spread, not unpredictability. Their internal state is small and often seeded from the clock, so an attacker can recover the state from a few outputs and predict all future (and past) values. Predictable "random" tokens are guessable tokens.

The fix. Use a CSPRNG: the OS source (getrandom, /dev/urandom, BCryptGenRandom) or a library function explicitly documented as cryptographically secure (secrets in Python, crypto.randomBytes in Node, RAND_bytes in OpenSSL).

When the rule applies. Anything an attacker should not predict: keys, nonces, salts, session IDs, CSRF tokens, password-reset links, API keys. It does not apply to non-security uses like a game's dice roll or a UI animation jitter — there, rand() is fine.

Pitfall. Seeding a CSPRNG with a predictable value, or calling srand(time(NULL)) and thinking it makes rand() secure. It does not.

8. JWT signing flaws

Definition. JSON Web Tokens are signed tokens carrying claims (like "user is admin"). The signature is what makes the claims trustworthy. Several classic misuses defeat that signature.

The big three:

  • alg:none — the token header declares "no signature." A server that honors it accepts unsigned tokens, so anyone can forge any claim.
  • Weak HMAC secret — HS256 signs with a shared secret. If that secret is short or guessable, an attacker brute-forces it offline and signs their own tokens.
  • RS256 → HS256 confusion — the server uses RSA (RS256) and verifies with the public key. An attacker changes the header to HS256 and signs the token using that public key as the HMAC secret. If the server uses the same key bytes for both algorithms, the forged token verifies.
legit:   header{alg:RS256} . claims . sign(private_key)   verify(public_key) -> ok
attack:  header{alg:none}  . claims{admin:true} . (no sig)  -> server MUST reject
attack:  header{alg:HS256} . claims . HMAC(public_key)       -> server MUST reject

The fix. Pin the expected algorithm on the verification side (do not let the token choose it), reject none, use a long random secret for HMAC, and keep signing keys in a vault. See the Web App Security track for deeper JWT coverage.

Pitfall. Trusting the alg field from the untrusted token itself to decide how to verify it. The verifier must decide the algorithm, not the attacker-controlled header.

Knowledge check (concept): Why is it a mistake for the server to read the alg value out of the incoming token and use that algorithm to verify the token?

Syntax notes

This is a concept lesson, so there is no language syntax to memorize. Instead, here is the mental "grammar" of safe vs unsafe choices — a quick lookup you can apply during review.

Need Unsafe choice Safe choice
Confidentiality + integrity ECB / CBC / CTR alone AEAD: AES-256-GCM, ChaCha20-Poly1305
Per-message uniqueness fixed/zero IV, reused nonce unique nonce per message (CSPRNG or non-repeating counter)
Random values for security rand(), Math.random CSPRNG: getrandom, secrets, crypto.randomBytes, RAND_bytes
Compare secrets/MACs ==, strcmp, memcmp constant-time compare (compare_digest, CRYPTO_memcmp)
Store keys/secrets hardcoded in source/config secrets manager / vault / env injected at deploy
Sign tokens (JWT) alg:none, weak HMAC secret, alg from token pin algorithm on verify, long random secret, vault keys
"Encrypt" data home-rolled XOR, base64 vetted library + standard construction

Annotated example of the single most important habit — choosing AEAD with a fresh nonce (pseudocode, library-agnostic):

key   = load_from_vault("data-key")        # never hardcoded
nonce = csprng_bytes(12)                    # unique per message, from a CSPRNG
ct, tag = AEAD_seal(key, nonce, plaintext,  # encrypt + authenticate in one step
                    associated_data=header) # bind context so it can't be moved
send(nonce, ct, tag, header)                # nonce is not secret; it must be sent

Lesson

Strong algorithms still fail when they are used incorrectly. These misuses are far more common than broken ciphers, and they are what you actually find in code review.

The common offenders

  • Rolling your own crypto. Home-made schemes are almost always broken. Use vetted libraries and standard constructions.

  • ECB mode (covered earlier). It leaks patterns in the data.

  • Static or reused IV/nonce. An IV (initialization vector) or nonce (a number used once) must be unique for every encryption, and for some modes it must also be random. Reuse breaks confidentiality. It is especially dangerous for stream ciphers and for GCM, where nonce reuse can leak the key stream and let an attacker forge messages.

  • Hardcoded keys and secrets. Keys placed in source code or in config files committed to a repository. This is a top finding. Search for them.

  • Unauthenticated encryption. Encrypting without integrity protection (no MAC, or a non-AEAD mode). This enables tampering and padding-oracle attacks. AEAD (Authenticated Encryption with Associated Data) combines encryption and integrity in one step.

  • Non-constant-time comparison. Comparing secrets or MACs with == or memcmp leaks timing information, which can make them forgeable. Use a constant-time comparison. The platform has a C exercise for exactly this.

  • Weak randomness. Using a non-cryptographic RNG such as rand() to generate keys or tokens. Use a CSPRNG instead.

  • JWT signing flaws. Examples include alg:none, weak HMAC secrets, and RS256-to-HS256 confusion. See the Web App Security track for more.

The takeaway

Cryptography is a minefield of usage, not just algorithms.

  • In review: look for these patterns.
  • In remediation: recommend vetted libraries, AEAD modes, unique nonces, CSPRNGs, constant-time comparisons, and secrets stored in a vault.

Code examples

Below is a realistic insecure-vs-secure comparison. The example is a small token-issuing and token-checking flow — the kind of code that protects password-reset links or API access — written as language-agnostic, runnable-style pseudocode so the focus stays on the cryptographic decisions, not on one library's API.

WARNING: Intentionally vulnerable training example — use only in a local, isolated, authorized lab. Do not deploy.

# === INSECURE VERSION ===  (do NOT deploy)

SECRET_KEY = "hunter2"                      # (1) hardcoded, short, guessable secret

function make_reset_token(user_id):
    r = rand() % 1000000                    # (2) non-crypto RNG -> predictable 6-digit code
    token = base64(user_id + ":" + r)       # (3) base64 is ENCODING, not encryption
    return token

function check_token(submitted, expected):
    return submitted == expected            # (4) early-exit compare -> timing leak

Now the secure version. Each fix maps to one numbered flaw above.

# === SECURE VERSION ===

SECRET_KEY = load_from_vault("reset-token-key")   # (1) long random key, stored in a vault

function make_reset_token(user_id):
    r = csprng_bytes(16)                           # (2) 128 bits from a CSPRNG -> unguessable
    payload = user_id + ":" + hex(r)
    tag = HMAC_SHA256(SECRET_KEY, payload)         # (3) real integrity protection, with a key
    return payload + "." + hex(tag)

function check_token(submitted):
    payload, tag = split_last(submitted, ".")
    expected = HMAC_SHA256(SECRET_KEY, payload)
    if not constant_time_equal(hex(expected), tag):  # (4) constant-time compare
        log_event("reset_token_invalid", user=payload_user(payload))  # log attempt, NOT the token
        return INVALID
    return payload                                   # token is authentic

What it does. The insecure version mints a guessable 6-digit code, "protects" it only with base64 (reversible by anyone), and checks it with a timing-leaky ==. An attacker can predict the code (small space + weak RNG), decode the base64 to read the structure, and probe the comparison by timing. The secure version generates 128 unpredictable bits from a CSPRNG, signs the payload with keyed HMAC so any tampering is detected, keeps the key in a vault, and verifies with a constant-time compare.

Expected behavior. A valid token returns the payload; an invalid or tampered token returns INVALID and logs the attempt (recording that it happened and which user, never the token value or the key). Because the token has 128 bits of entropy and the compare is constant-time, neither guessing nor timing gives the attacker traction.

Key edge cases. (a) A token with the right payload but a wrong tag must be rejected — that is the whole point of the HMAC. (b) Reset tokens should also expire and be single-use; the crypto fixes confidentiality/integrity but not replay. (c) split_last on a malformed token must fail safely (treat as invalid), not crash.

Line by line

Walking the secure make_reset_token / check_token path, step by step:

Step What happens What is in play
1 load_from_vault("reset-token-key") The secret is fetched at runtime from a secrets manager. It never appears in source or git history.
2 csprng_bytes(16) The OS CSPRNG returns 16 unpredictable bytes (128 bits). An attacker cannot recover the RNG state from outputs, so future tokens stay unpredictable.
3 payload = user_id + ":" + hex(r) The clear part of the token. It is not secret — anyone can read it — but it is about to be bound to a signature.
4 HMAC_SHA256(SECRET_KEY, payload) Produces a keyed tag. Only someone holding the vault key can compute a matching tag, so the tag proves authenticity and detects any change to payload.
5 return payload + "." + hex(tag) The wire format: readable payload plus its tag.

Verification (check_token):

Step What happens Why the result follows
1 split_last(submitted, ".") Separates the claimed payload from the claimed tag. Malformed input -> treated as invalid.
2 expected = HMAC_SHA256(SECRET_KEY, payload) Recompute the tag the server would have produced for this payload.
3 constant_time_equal(expected, tag) Compare every byte regardless of where a mismatch occurs. Equal time whether the first byte or no byte matches, so timing reveals nothing.
4a mismatch -> log + return INVALID The attempt is recorded (event + user), but the submitted token and the key are never logged.
4b match -> return payload The tag could only be produced with the key, so the payload is authentic.

The critical insight: in the insecure version, the time taken by submitted == expected is data the attacker can read. In the secure version, that timing channel is closed because the compare's duration no longer depends on the secret's contents.

Common mistakes

Mistake 1 — "Base64 / hex / XOR is encryption." Wrong:

token = base64("user42:admin")   # "looks scrambled, so it's safe"

Why it's wrong: base64 has no key; anyone decodes it instantly. XOR with a fixed constant is equally trivial to reverse. Neither keeps a secret. Corrected: use a real keyed primitive (HMAC for integrity, an AEAD cipher for confidentiality+integrity). Recognize/prevent: if there is no key involved, it is not encryption. Ask "what does an attacker need that they don't have?" If the answer is "nothing," it is encoding.

Mistake 2 — Fixed or reused nonce "to keep things simple." Wrong:

IV = bytes(16)            # all zeros, reused for every message
ct = AES_GCM(key, IV, plaintext)

Why it's wrong: under one key, a repeated GCM nonce leaks plaintext relationships and can expose the auth key, enabling forgery. Corrected: IV = csprng_bytes(12) (or a guaranteed-non-repeating counter) for every single message; transmit the nonce alongside the ciphertext. Recognize/prevent: any IV/nonce that is a constant, derived from stable data (like a user id), or reset on restart is a red flag.

Mistake 3 — Encrypting without authenticating. Wrong: AES-CBC with no MAC, then trusting the decrypted bytes. Why it's wrong: attackers can tamper with ciphertext undetected; padding-oracle behavior can leak the plaintext. Corrected: use an AEAD mode (GCM, ChaCha20-Poly1305) that verifies a tag before returning plaintext. Recognize/prevent: if you see a cipher mode but no integrity tag anywhere, it is unauthenticated.

Mistake 4 — Comparing secrets with == / memcmp. Wrong:

if user_mac == real_mac: accept()

Why it's wrong: early-exit timing lets an attacker recover the MAC byte by byte. Corrected: if constant_time_equal(user_mac, real_mac): accept(). Recognize/prevent: any equality check on a secret, token, hash, or MAC should use a constant-time function.

Mistake 5 — rand() for security values. Wrong: token = rand() for a session ID or reset code. Why it's wrong: predictable state -> guessable tokens. Corrected: draw from a CSPRNG. Recognize/prevent: scan for rand, random, Math.random, srand near anything called token/key/nonce/salt/session.

Mistake 6 — Letting the JWT choose its own algorithm. Wrong: read header.alg from the incoming token and verify with that algorithm. Why it's wrong: an attacker sets alg:none or swaps RS256→HS256 to forge tokens. Corrected: hardcode the accepted algorithm(s) on the verifier; reject none. Recognize/prevent: verification code should never branch on an attacker-supplied alg.

Debugging tips

Because this is conceptual, "debugging" means spotting and confirming a misuse rather than fixing a compiler error. Concrete steps:

Reviewing code for these patterns

  • Grep for the dangerous building blocks: ECB, rand(, srand, Math.random, memcmp, strcmp, == near mac/hmac/token/hash, alg, none, hardcoded-looking strings near key/secret/password.
  • Use a secrets scanner (e.g. a git-history secret scanner) to catch committed keys, including in old commits.
  • For nonce reuse, trace where the IV comes from: is it freshly generated per call, or stored/constant/derived from stable input?

Confirming a finding (mitigation verification / how to TEST the fix)

  • Constant-time compare: after the fix, the check must still return correct equal/not-equal results for matching, non-matching, and different-length inputs. Verifying timing precisely is hard and noisy; rely primarily on using a vetted constant-time function rather than trying to measure nanoseconds yourself.
  • Nonce uniqueness: in a lab, capture many ciphertexts and assert that no nonce repeats. A repeat is an immediate failure.
  • AEAD integrity: flip one byte of a ciphertext (in a local test) and confirm decryption is rejected, not silently altered. If altered plaintext comes out, integrity is missing.
  • JWT alg:none: in a local test harness, submit a token with alg:none and confirm the server rejects it. Submit an HS256 token signed with the RSA public key and confirm rejection.
  • Weak RNG: confirm tokens come from a documented CSPRNG; check entropy/length (e.g. >=128 bits) rather than statistical randomness alone.

Logic-error symptoms to watch for

  • "It decrypts fine but accepts tampered data" -> missing authentication (use AEAD).
  • "Two different messages produced the same ciphertext" -> ECB or a reused nonce.
  • "Tokens are sequential or short" -> weak RNG or tiny space.

Questions to ask when something looks off

  • Where does the key live, and could it be in git history?
  • Is the IV/nonce unique for every encryption under this key?
  • Is integrity verified before plaintext is used?
  • Is every secret comparison constant-time?
  • Does the verifier, not the token, decide the JWT algorithm?

Memory safety

Security & safety

Authorization & ethics. Everything here is defensive. Practice only on systems you own or are explicitly authorized to test — localhost, containers, intentionally vulnerable lab VMs, or CTF targets. Never test these techniques against third-party systems, and never use the insecure examples outside an isolated lab. The intentionally vulnerable snippet in this lesson is a training artifact, not deployable code.

Threat model (text diagram). Knowing what you are protecting and from whom focuses the fixes.

ASSETS                TRUST BOUNDARY                ENTRY POINTS / ATTACKER
-----------------     ----------------------        ------------------------
encryption keys   ===|  application / server  |===  - submits tokens/JWTs
signing secrets      |  (holds the secrets)   |     - reads ciphertext on wire
user data (PT)       |                        |     - measures response timing
session tokens    ===|  vault / KMS (keys)    |===  - reads source / git history
                      ----------------------        - controls 'alg' header

The attacker is outside the trust boundary but can send input, observe outputs (including timing), and may read code or leaked repos. Every misuse in this lesson hands the attacker a way across that boundary.

Defensive practices, ranked.

  1. Prefer prevention. Use vetted libraries, AEAD modes, unique CSPRNG nonces, constant-time compares, and a secrets vault. Most of this is "don't write it yourself."
  2. Make the safe path the default. Wrap crypto in a small internal module so application code cannot pick ECB, a fixed IV, or rand() by accident.
  3. Detect. Run secret scanners in CI; lint for rand()/memcmp near secrets; alert on bursts of invalid-token or invalid-MAC events (a sign of byte-by-byte probing).
  4. Remediate. Treat any committed secret as compromised: rotate the key, invalidate issued tokens, and purge where feasible.

Logging guidance. Log security-relevant events: token validation failures, signature mismatches, repeated reset attempts, with enough context (timestamp, user id, source) to investigate. Never log secrets — not keys, passwords, tokens, MACs, nonces tied to keys, or full ciphertext. A log line that contains the very secret you are protecting is itself a vulnerability.

Real-world uses

Concrete real-world cases.

  • TLS / HTTPS relies on AEAD ciphers (AES-GCM, ChaCha20-Poly1305) precisely to avoid the unauthenticated-encryption and padding-oracle problems that plagued older CBC-based suites. Nonce management is built into the protocol.
  • Web sessions and API auth depend on CSPRNG-generated session IDs and tokens; predictable IDs have led to real account-takeover bugs.
  • JWT-based auth (used across SaaS and microservices) is a frequent source of alg:none and RS256→HS256 findings; pinning the algorithm on the verifier is the standard mitigation.
  • Secrets management (vaults, cloud KMS, sealed secrets) exists because hardcoded keys are one of the most common audit findings; leaked-secret scanners run on millions of repos.
  • Encrypted storage (databases, backups, disk encryption) uses AEAD with per-record/per-block unique nonces so that identical records do not reveal that they are identical.

Professional best-practice habits.

Beginner rules (always):

  • Never invent your own crypto; use a maintained, well-known library.
  • Default to AEAD; never ship plain ECB.
  • One unique CSPRNG nonce per encryption.
  • Keep keys out of source; use placeholders like <development-placeholder> in examples.
  • Compare secrets with a constant-time function.

Advanced rules (as you grow):

  • Centralize crypto behind a small, reviewed wrapper so misuse is structurally hard.
  • Plan for key rotation and versioned keys from day one; tag ciphertext with a key id.
  • Bind context with AEAD associated data to prevent replay/relocation of ciphertext.
  • Add CI gates: secret scanning, crypto-misuse lint rules, and tests that flip ciphertext bytes to prove integrity is enforced.
  • Monitor for abuse signals (spikes in invalid-MAC/invalid-token events) and rate-limit verification endpoints. These habits trade a little upfront discipline for far cheaper audits, faster incident response, and code that resists the exact findings this lesson covers.

Practice tasks

Work these in order; each builds on the last. Do not look up full solutions first — reason it through.

Beginner 1 — Spot the misuse (review drill). Objective: classify each of five short snippets as one of the named misuses (home-rolled, ECB, nonce reuse, hardcoded key, unauthenticated, timing leak, weak RNG, JWT flaw) or "safe." Requirements: write the misuse name and one sentence on why for each. Use snippets like IV = bytes(12) reused across calls, token = rand() % 100000, if mac == expected, KEY = "s3cr3t", and one correct AEAD-with-fresh-nonce example. Hints: ask "is there a key?", "is the nonce unique?", "is integrity checked?". Concepts: all core concepts.

Beginner 2 — Encoding vs encryption. Objective: demonstrate in writing why base64 is not encryption. Requirements: take the string user42:role=admin, show its base64 form, then describe exactly the steps an attacker takes to read and to modify it (no key needed). Then state what you would use instead to (a) keep it secret and (b) detect tampering. Example I/O: input user42:role=admin -> base64 dXNlcjQyOnJvbGU9YWRtaW4= -> decoded back to the original. Hints: encryption needs a key; integrity needs a MAC. Concepts: home-rolled crypto, AEAD, HMAC.

Intermediate 1 — Insecure-to-secure rewrite. Objective: take the intentionally vulnerable make_reset_token/check_token pseudocode from this lesson and rewrite it securely, then list each fix mapped to its flaw number. Requirements: replace the hardcoded secret (vault), the weak RNG (CSPRNG, >=128 bits), the base64 "protection" (keyed HMAC), and the == (constant-time compare). Add token expiry and single-use to defend replay. Constraints: do not invent library functions; describe them generically ("a documented CSPRNG", "a constant-time compare"). Hints: one fix per numbered flaw, plus replay. Concepts: hardcoded keys, weak RNG, integrity/HMAC, constant-time compare.

Intermediate 2 — Design a nonce strategy. Objective: specify, in writing, a nonce scheme for a service that encrypts many messages per second under one AES-GCM key. Requirements: state how nonces are generated, why they cannot repeat, what happens on process restart or crash, and how you would test (in a lab) that no nonce repeats across, say, 1,000,000 messages. Constraints: must survive restarts without reusing a nonce. Hints: random 96-bit nonces vs a persisted counter — discuss the trade-off (collision probability vs state management). Concepts: nonce reuse, CSPRNG.

Challenge — JWT verification threat model + safe verifier spec. Objective: write a short spec for a JWT verifier that resists alg:none, weak-secret, and RS256→HS256 attacks, and a lab test plan to prove it. Requirements: (1) a small threat-model diagram (assets, trust boundary, attacker-controlled inputs including the alg header); (2) the verifier rules (algorithm pinned on the server, none rejected, secret length/source requirements, key storage); (3) three lab test cases — a forged alg:none token, an HS256 token signed with the RSA public key, and a tampered-claims token — each with the expected result (reject) and what to log (event + reason, never the token or key). Constraints: defensive only, localhost/lab. Hints: the verifier, not the token, decides the algorithm. Concepts: JWT flaws, constant-time compare, logging guidance.

Summary

Cryptography almost never fails because the algorithm was broken — it fails because the algorithm was used wrong. The recurring misuses are: home-rolled crypto (and mistaking encoding for encryption), ECB mode, IV/nonce reuse, hardcoded keys, unauthenticated (non-AEAD) encryption, non-constant-time comparison, weak (non-CSPRNG) randomness, and JWT signing flaws (alg:none, weak HMAC secrets, RS256→HS256 confusion).

The safe replacements are equally memorable: use vetted libraries and standard constructions; default to AEAD (AES-GCM, ChaCha20-Poly1305); generate a unique nonce per message; draw security values from a CSPRNG; compare secrets in constant time; keep keys in a vault, never in source; and on the JWT verifier, pin the algorithm and reject none.

The most common mistakes to internalize: "it looks scrambled so it's safe" (encoding != encryption), a fixed/reused nonce "for simplicity," encrypting without integrity, ==/memcmp on secrets, rand() for tokens, and trusting the token's own alg. What to remember: in review, hunt the pattern; in remediation, name the vetted fix and how to test it; and never log the secret you are protecting. All practice here is defensive and lab-only.

Practice with these exercises