Cryptography is the part of security where good intentions go to die. I’ve reviewed a lot of applications over the years, and almost nobody sets out to do crypto badly. They reach for it because they care about protecting data - and then they hash passwords with MD5, store an AES key in the source code, or build their own “encryption” out of XOR and a hardcoded string. The intent was right. The execution quietly undid it.
That’s why I’m glad OWASP renamed this category. It used to be “Sensitive Data Exposure,” which described the symptom. “Cryptographic Failures” describes the cause. The data gets exposed because the crypto protecting it was weak, misused, or missing entirely. And the failures are remarkably consistent - I see the same handful of mistakes over and over, across languages and teams. The good news is that means the fixes are consistent too.
Quick Answer: What is a Cryptographic Failure?
Cryptographic Failures happen when sensitive data isn’t properly protected because cryptography is missing, weak, or used incorrectly. The result is the same whether the cause is no encryption at all or encryption done wrong: data that should be unreadable to an attacker isn’t.
Why it’s high on the 2025 list: Nearly every application handles something that needs protecting - passwords, payment data, personal information, session tokens, API keys. When the protection fails, the blast radius is enormous, because this data is exactly what attackers are after.
The most common failures I find:
- Weak password hashing (MD5, SHA-1, or unsalted hashes) instead of a real password hash
- Sensitive data stored in plaintext in databases, logs, or backups
- Hardcoded keys and secrets committed to source control
- Weak randomness used for tokens, passwords, or session IDs
- Outdated or misconfigured TLS leaving data exposed in transit
- Home-grown crypto instead of vetted, standard libraries
Why Cryptographic Failures Stay Near the Top
Here’s the thing about crypto bugs: they’re invisible until they’re catastrophic. A broken access control gets noticed because someone sees data they shouldn’t. A SQL injection shows up in error logs. But a database full of MD5-hashed passwords looks completely fine right up until it’s dumped and cracked in an afternoon.
The Failures Don’t Look Like Failures
I’ve sat in code reviews where everyone nodded along at hashlib.md5(password) because, hey, it’s hashed, right? It looks like security. The code runs, the tests pass, the password isn’t stored “in plaintext.” But MD5 is so fast that an attacker with a consumer GPU can try billions of guesses per second. A hash that’s fast to compute is a hash that’s fast to crack. The whole point of a password hash is to be deliberately slow.
That gap - between code that looks secure and code that is secure - is what keeps this category near the top of the list year after year.
Real Scenarios That Stuck With Me
- The plaintext password reset. An app I assessed emailed users their existing password when they hit “forgot password.” That’s only possible if the password is stored in a recoverable form - which means it was never properly hashed at all. The email was just the visible symptom of a much worse storage decision.
- The hardcoded key in the mobile app. A team encrypted data on the client with AES - good instinct - but shipped the key inside the app binary. Anyone who decompiled it had the key. Encryption with a key the attacker can read is just obfuscation with extra steps.
- The predictable token. A password-reset token generated with Python’s
randommodule looked random enough to humans, butrandomis a Mersenne Twister - not cryptographically secure. With enough observed outputs, its future values are predictable, which meant reset tokens could be guessed.
None of these were exotic attacks. They were ordinary crypto mistakes, and each one fully compromised the data it was supposed to protect.
Fixing the Failures, One Pattern at a Time
Let me walk through the specific mistakes and their fixes. This is the part I’d actually hand to a team, roughly in priority order.
1. Hash Passwords With a Real Password Hash
This is the single most important one. Passwords must never be stored reversibly, and they must never be hashed with a general-purpose fast hash (MD5, SHA-1, SHA-256). Use a slow, salted, purpose-built password hash: Argon2 (my first choice today) or bcrypt.
# WRONG - fast hash, trivially cracked, no salt
import hashlib
bad = hashlib.md5(password.encode()).hexdigest() # never do this
also_bad = hashlib.sha256(password.encode()).hexdigest() # still wrong for passwords
# RIGHT - Argon2, the modern default
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher() # sensible, memory-hard defaults
def hash_password(password: str) -> str:
# Salt is generated automatically and stored inside the hash string
return ph.hash(password)
def verify_password(stored_hash: str, password: str) -> bool:
try:
ph.verify(stored_hash, password)
# Transparently upgrade the hash if parameters have strengthened
if ph.check_needs_rehash(stored_hash):
# caller should persist a freshly-hashed value here
pass
return True
except VerifyMismatchError:
return False
# RIGHT - bcrypt, still perfectly fine and widely supported
import bcrypt
def hash_password(password: str) -> bytes:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))
def verify_password(stored_hash: bytes, password: str) -> bool:
return bcrypt.checkpw(password.encode(), stored_hash)
The “slow on purpose” property is the whole point. Argon2 and bcrypt let you tune a work factor so that one verification takes a fraction of a second for your server but makes mass cracking economically painful for an attacker.
2. Encrypt Sensitive Data at Rest - Correctly
For data you genuinely need to store and read back (not passwords - those you hash), use authenticated encryption. The key word is authenticated: it protects against tampering as well as reading. In Python, the easiest safe option is Fernet from the cryptography library, or AES-GCM if you need more control.
# Simple and safe: Fernet (AES-128-CBC + HMAC, authenticated)
from cryptography.fernet import Fernet
# Generate ONCE, store securely (see key management below) - never in code
key = Fernet.generate_key()
f = Fernet(key)
token = f.encrypt(b"sensitive value") # encrypts + authenticates
plain = f.decrypt(token) # raises on tampering or wrong key
# More control: AES-256-GCM with a fresh nonce per message
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key = AESGCM.generate_key(bit_length=256) # store securely, do not hardcode
aesgcm = AESGCM(key)
def encrypt(plaintext: bytes, aad: bytes = b"") -> bytes:
nonce = os.urandom(12) # NEVER reuse a nonce with the same key
return nonce + aesgcm.encrypt(nonce, plaintext, aad)
def decrypt(blob: bytes, aad: bytes = b"") -> bytes:
nonce, ct = blob[:12], blob[12:]
return aesgcm.decrypt(nonce, ct, aad) # raises if tampered
Two failure modes to avoid that I still see regularly: ECB mode (it leaks patterns in your data - identical plaintext blocks produce identical ciphertext) and nonce reuse with GCM (catastrophic - it can leak the authentication key). Use a fresh random nonce every time, and never use ECB for anything.
3. Never Hardcode Keys or Secrets
Encryption is only as strong as your key management. A perfect AES implementation is worthless if the key is sitting in your Git history. Keys and secrets belong in environment variables, a secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.), or a KMS - never in source code, and never in the repo.
import os
# Pull the key from the environment / secrets manager at runtime
key = os.environ.get("DATA_ENCRYPTION_KEY")
if not key:
raise RuntimeError("DATA_ENCRYPTION_KEY is not set - refusing to start")
And if a secret does end up in a commit, treat it as compromised and rotate it. Removing it in a later commit doesn’t help - it’s still in the history, and bots scrape public repos for exactly this within minutes. I’ve watched leaked keys get used faster than the developer could finish saying “oops.”
4. Use Cryptographically Secure Randomness
This one bites people constantly because Python has two random modules that look interchangeable and absolutely are not. random is fast and predictable, built for simulations - never for security. For anything an attacker shouldn’t be able to guess, use secrets.
# WRONG - predictable, not for security
import random
token = "".join(random.choice("abcdef0123456789") for _ in range(32))
# RIGHT - cryptographically secure
import secrets
token = secrets.token_urlsafe(32) # session IDs, reset tokens, API keys
otp = secrets.randbelow(1_000_000) # numeric codes
api_key = secrets.token_hex(32)
Anything used as a session ID, password reset token, API key, OTP, or nonce must come from secrets (or os.urandom), not random. The difference is invisible in testing and total in a real attack.
5. Protect Data in Transit With Modern TLS
Encrypting data at rest doesn’t help if it crosses the network in the clear. Enforce HTTPS everywhere, use TLS 1.2 or 1.3, disable old protocols (SSLv3, TLS 1.0/1.1) and weak ciphers, and never disable certificate verification “to make it work.”
# WRONG - disables the protection entirely
import requests
requests.get("https://api.example.com", verify=False) # man-in-the-middle wide open
# RIGHT - verification on (the default); fix the actual cert issue instead
requests.get("https://api.example.com")
verify=False is the crypto equivalent of taping over a warning light. If you’re hitting a certificate error, fix the certificate or pin the proper CA bundle - don’t switch off the verification that makes TLS meaningful.
6. Don’t Roll Your Own Crypto
I know it’s tempting, especially for smart engineers who like a challenge. Resist it. Cryptographic primitives have edge cases - timing side channels, padding oracles, nonce handling - that have taken the world’s best cryptographers decades to get right. Use vetted, maintained libraries (cryptography, PyNaCl, bcrypt, argon2-cffi) and standard constructions. Your custom XOR cipher, your clever homemade token scheme, your “encryption” that’s really just base64 - I’ve seen all of them, and I’ve broken all of them. The library that thousands of people have audited will beat the one you wrote on a Friday afternoon every single time.
Migrating Off Weak Hashes Without a Mass Reset
The most common objection I hear when I tell a team their password hashing is broken: “We have two million users on MD5 - we can’t exactly email all of them to reset.” Fair. You don’t have to, and you shouldn’t force a giant reset if you can avoid it. There are two clean migration paths, and I’ve used both.
Option 1: Rehash on next login. Keep the old hashes, but the moment a user successfully authenticates, you have their plaintext password in hand for that instant - so re-hash it with Argon2 and replace the old value. Over a few weeks, your active users migrate themselves transparently. Mark each row with which scheme it uses so you know how to verify it.
from argon2 import PasswordHasher
import hashlib, hmac
ph = PasswordHasher()
def verify_and_upgrade(user, password: str) -> bool:
if user.hash_scheme == "argon2":
try:
ph.verify(user.password_hash, password)
return True
except Exception:
return False
# Legacy MD5 path - verify with constant-time compare, then upgrade
legacy = hashlib.md5(password.encode()).hexdigest()
if not hmac.compare_digest(legacy, user.password_hash):
return False
# Correct password: transparently migrate this user to Argon2
user.password_hash = ph.hash(password)
user.hash_scheme = "argon2"
user.save()
return True
Option 2: Wrap the old hash immediately. If you want every account protected today without waiting for logins, re-hash the existing weak digests in bulk: store argon2(md5(password)). Now even a database dump exposes Argon2-strength hashes, not crackable MD5. At the next login you peel off the wrapper and store a clean argon2(password). This is my preferred approach when the legacy scheme is truly weak, because it closes the exposure window instead of leaving it open until each user happens to log in.
Either way, the key point is that “we have legacy hashes” is not a reason to stay on broken crypto. It’s a migration problem with well-trodden solutions, not a dead end.
A Quick Crypto Self-Audit
When I do a first pass on an application’s crypto posture, I’m really just grepping for the usual suspects. You can do the same in a few minutes:
- Search for
md5,sha1, and unsaltedsha256near anything password-related. - Search for
random.in security-sensitive code (tokens, keys, OTPs). - Search for
verify=Falseand disabled certificate checks. - Grep the repo - and the Git history - for anything that looks like a key or secret (
KEY =,SECRET, long hex/base64 literals). - Look for
ECBmode anywhere. - Check that passwords go through bcrypt/argon2 and that data-at-rest uses authenticated encryption.
Each hit is a thread to pull. Most cryptographic failures I find start with one of these greps.
Key Takeaways
After years of finding the same crypto mistakes, here’s what I’d put on the wall:
The Mindset
- “Hashed” is not “secure.” A fast hash on a password is barely better than plaintext. Use a slow, salted password hash.
- Encryption is only as strong as key management. A great cipher with a hardcoded key protects nothing.
- Looks-random isn’t random. Use
secrets, notrandom, for anything an attacker shouldn’t predict.
What Actually Works
- Argon2 or bcrypt for passwords - never MD5/SHA for credentials.
- Authenticated encryption (Fernet or AES-GCM) for data at rest - never ECB, never nonce reuse.
- Keys in a secrets manager, never in source; rotate anything that leaks.
secretsmodule for tokens, keys, and OTPs.- Modern TLS everywhere, with verification left on.
- Use real crypto libraries - don’t invent your own.
Cryptographic Failures earn their spot near the top of the list because the data they protect is exactly what attackers want, and the mistakes are quiet enough to ship without anyone noticing. The encouraging part is how repetitive the failures are. Fix password hashing, fix randomness, fix key storage, and turn verification on - do those four things and you’ve closed the door on the overwhelming majority of crypto bugs I find in the wild.