GUIDE · OWASP Top 10:2025

A04:2025 Cryptographic Failures

Sensitive data exposed because crypto is missing, weak, or misused — in transit, at rest, or in how secrets/keys are handled.

lesson7 sectionsnode only2021: A02 same name, down from #226 asserts186 lines

At a glance

FIRST VISIT
From Session 1.4, read the table in section 0 and stop; the rest is Unit 10.
WHAT
Sensitive data exposed because crypto is missing, weak, or misused — in transit, at rest, or in how secrets/keys are handled.
HAPPENS
plaintext passwords; MD5/SHA1 (fast hash) for passwords; no salt; hardcoded keys in repo; AES-ECB (pattern leakage); static/predictable IV; Math.random() for tokens; HTTP instead of TLS / TLS 1.0; JWT alg: none or HS256 verified with the RSA public key; custom crypto; non-constant-time compare; secrets in logs/URLs.
SPOT
createHash('md5'|'sha1') near 'password'; Math.random() near 'token'|'reset'|'session'; createCipheriv with fixed iv or 'ecb'; === on HMACs/tokens; jwt.decode without verify; KEY = '...' literal; http:// URLs for APIs.
FIX
passwords: argon2id / bcrypt / scrypt (slow, salted, memory-hard); tokens: crypto.randomBytes / randomUUID; compare: timingSafeEqual; symmetric: AES-256-GCM with fresh random 12-byte IV + auth tag; integrity: HMAC-SHA256; keys from KMS/env, rotated; TLS 1.2+ with HSTS; JWT: pin algorithm allowlist, verify before trust.
SAY
CWE-327 Broken/Risky Crypto Algorithm, CWE-328 Reversible One-Way Hash, CWE-330 Insufficiently Random Values, CWE-798 Hardcoded Credentials, CWE-319 Cleartext Transmission, CWE-208 Timing Discrepancy. AEAD, KDF, nonce/IV, salt, pepper, key rotation. Pitch: "Don't roll your own; use AEAD, a real KDF, CSPRNG, constant-time compares, and keep keys out of code."

The idea#

Picture a diary written in pencil and shut with a toy lock from a party favor. The pages hold real secrets. The pencil erases. The toy lock pops if you have seen one before. The protection was pretend.

That is a Cryptographic Failure. Sensitive data is exposed because protection is missing, weak, or used in the wrong shape: on the wire, on disk, or in the way you create and compare secrets. The controls are boring on purpose, and that is the point. Passwords go through a slow salted key derivation function. This page uses scrypt, a fresh salt every time, and a constant-time compare of the result. Tokens and session ids come from crypto.randomBytes or crypto.randomUUID. Encryption of data uses AES-256-GCM, a fresh 12-byte IV for every message, and an auth tag that fails the open when the ciphertext or the associated data changes. A cookie or a webhook you must believe is HMAC-SHA256. Keys come from the environment or a key manager. A JWT is accepted only after verification, and only for an algorithm on your allowlist.

Failure looks like the pencil diary. Passwords sit in the clear, or they run through a fast hash such as MD5 or SHA-1 with no salt. A reset token is the clock plus a user id. A compare with === finishes early on the first wrong byte and turns time into a hint. A fixed IV, a key pasted into the source, or a mode with no integrity lets someone rewrite or predict the secret. A token is trusted from a decode that skipped the signature, including a header whose algorithm is none.

Why it matters#

A crypto failure turns a backup, a log line, or a copied cookie into the thing you meant to protect. A fast password hash is an easy puzzle. A predictable token is a guess. Ciphertext with no auth tag can be altered and still come back as data your program accepts. A skipped signature is a diary page someone else rewrote.

Call the library the platform already reviewed. Salt each password so two identical phrases do not share a stored value, and slow the hash so guessing costs time and memory. Draw 32-byte tokens from the operating system's random source. Compare secrets with timingSafeEqual. Encrypt with an authenticated mode and a new IV per message. Sign anything the client will hand back, verify before you read it, and keep the key out of the repository.

A small example#

JS
const crypto = require('node:crypto'); function newToken() {  return crypto.randomBytes(32).toString('base64url');} function safeEqual(a, b) {  const left = Buffer.from(String(a));  const right = Buffer.from(String(b));  if (left.length !== right.length) return false;  return crypto.timingSafeEqual(left, right);} const issued = newToken();const match = safeEqual(issued, issued);const mismatch = safeEqual(issued, newToken());

issued is 32 random bytes, encoded for a header or a link. match is true. mismatch is false. Equal-length compares go through timingSafeEqual.

What the code below is doing#

Six demonstrations. Each one is a weak habit and the library call that replaces it.

Password storage is hashPassword and verifyPassword. The hash is scrypt. The stored string holds the algorithm name, the cost, the salt, and the hash. Verify splits that string, recomputes scrypt, and compares with timingSafeEqual. Two hashes of the same password differ because the salt is new each time. The password text is absent from the stored string.

Random tokens are newToken. It reads crypto.randomBytes and renders them as base64url. An assert checks that crypto.randomUUID matches the version-4 shape.

Constant-time compare is safeEqual. Matching bytes return true. A difference returns false. Different lengths return false up front, because timingSafeEqual requires equal lengths.

Symmetric encryption is encrypt and decrypt with AES-256-GCM. The IV is 12 random bytes, the auth tag rides with the ciphertext, and associated data is bound in. A flipped byte, the wrong associated data, or the wrong key throws. Two encryptions of the same text differ because the IV is fresh. The key on the page is random bytes, a stand-in for a key from the environment or a key manager.

HMAC integrity is sign and verify. The body is base64url JSON. The MAC is HMAC-SHA256. Verify returns the object only when safeEqual accepts the MAC. A body swapped to a different role, still wearing the old MAC, returns null. The wrong key returns null.

The JWT check is headerAlgAllowed. It reads the header and returns true only when alg is on the allowlist you passed. A header of none fails a list that contains RS256. The comment says production code should call a real verify function with that same allowlist.

When you talk about it, say this: slow salted password hashes, random bytes for tokens, constant-time compares, authenticated encryption, and keys that live outside the source.

Everything below is the demonstration. Same idea, written as code you can run.

JSowasp/a04-cryptographic-failures.js · setup
'use strict';const assert = require('node:assert/strict');const crypto = require('node:crypto');

Not run in the browser: uses node:crypto. Copy the node command and run it locally.

00

Three jobs, three tools#

JS
// If you came from Session 1.4: three different questions get called "crypto". Pick the job first,// then the tool. Each row below runs once in code right after the table.////   Job                        Tool                       Input              Output//   hide data                  AES-256-GCM (encryption)   plaintext + key    ciphertext; the same key reverses it//   check it was not altered   HMAC-SHA256                message + key      a short tag you compare; change one//                                                                            byte of the message and the tag changes//   store a password           slow salted hash           password           a hash you can only check, never//                              (scrypt here, argon2id                        reverse; the password is not in it//                              in production)//// The SSH key pair from 1.4 does the middle job with two keys instead of one: your private key makes the// tag, the public key checks it, so the public key proves who you are. That is the signature job.//// Stop here on your first visit from Session 1.4. The code below is JavaScript, which you meet in Unit 3; it runs// each row of the table once so the page can check itself. Sections 1 to 6 are for Unit 10, when the capstone// stores a password.const jobKey = crypto.randomBytes(32);                                   // one 256-bit secret for the demos below // hide data: encrypt, then decrypt with the same key and get the exact text backconst jobIv = crypto.randomBytes(12);const hider = crypto.createCipheriv('aes-256-gcm', jobKey, jobIv);const hidden = Buffer.concat([hider.update('meet at the north gate', 'utf8'), hider.final()]);const hiddenTag = hider.getAuthTag();const opener = crypto.createDecipheriv('aes-256-gcm', jobKey, jobIv);opener.setAuthTag(hiddenTag); // check it was not altered: same message + same key = same tag; one byte changed = different tagconst tagFor = (message) => crypto.createHmac('sha256', jobKey).update(message).digest('hex'); // store a password: the hash verifies the password, contains no trace of it, and cannot be turned back into itconst jobSalt = crypto.randomBytes(16);const pwHash = crypto.scryptSync('open sesame', jobSalt, 32);
Verified by 7 asserts
JS
assert.equal(hidden.toString('utf8').includes('north gate'), false);    // ciphertext does not contain the wordsassert.equal(Buffer.concat([opener.update(hidden), opener.final()]).toString('utf8'), 'meet at the north gate');  // reversibleassert.equal(tagFor('pay 100 gold'), tagFor('pay 100 gold'));assert.notEqual(tagFor('pay 100 gold'), tagFor('pay 900 gold'));        // '1' became '9': the tag no longer matchesassert.equal(pwHash.includes(Buffer.from('open sesame')), false);        // the password is not inside the hashassert.equal(crypto.timingSafeEqual(crypto.scryptSync('open sesame', jobSalt, 32), pwHash), true);   // right password checks outassert.equal(crypto.timingSafeEqual(crypto.scryptSync('open sesamE', jobSalt, 32), pwHash), false);  // one letter off fails
01

Password storage: slow salted KDF (scrypt built-in; argon2id / bcrypt via libs)#

JS
// VULNERABLE://   db.users.insert({ email, password });                                  // plaintext//   db.users.insert({ email, password: md5(password) });                   // rainbow tables, GPU 10^10/s//   db.users.insert({ email, password: sha256(password) });                // still fast, unsalted// FIXED:const SCRYPT = { N: 2 ** 14, r: 8, p: 1, keylen: 64 };function hashPassword(password) {  const salt = crypto.randomBytes(16);  const hash = crypto.scryptSync(password, salt, SCRYPT.keylen, SCRYPT);  return `scrypt$${SCRYPT.N}$${salt.toString('base64')}$${hash.toString('base64')}`;}function verifyPassword(password, stored) {  const [algo, n, saltB64, hashB64] = stored.split('$');  if (algo !== 'scrypt') return false;  const expected = Buffer.from(hashB64, 'base64');  const actual = crypto.scryptSync(password, Buffer.from(saltB64, 'base64'), expected.length, { ...SCRYPT, N: Number(n) });  return crypto.timingSafeEqual(actual, expected);   // constant-time, not ===}const stored = hashPassword('correct horse battery staple');
Verified by 4 asserts
JS
assert.equal(verifyPassword('correct horse battery staple', stored), true);assert.equal(verifyPassword('wrong', stored), false);assert.notEqual(hashPassword('same'), hashPassword('same'));   // unique salt per userassert.equal(stored.includes('correct horse'), false);
02

Random tokens: CSPRNG, never Math.random()#

JS
// VULNERABLE://   const resetToken = Math.random().toString(36).slice(2);   // predictable PRNG, ~50 bits, seedable//   const sessionId = Date.now() + userId;                    // guessable// FIXED:function newToken(bytes = 32) { return crypto.randomBytes(bytes).toString('base64url'); }const t1 = newToken();
Verified by 3 asserts
JS
assert.equal(Buffer.from(t1, 'base64url').length, 32);        // 256 bits of entropyassert.notEqual(t1, newToken());assert.match(crypto.randomUUID(), /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
03

Constant-time comparison#

JS
// VULNERABLE://   if (providedToken === storedToken) ...   // early-exit on first mismatched byte => timing oracle// FIXED:function safeEqual(a, b) {  const ab = Buffer.from(String(a));  const bb = Buffer.from(String(b));  if (ab.length !== bb.length) return false;                   // length leak acceptable; use HMAC of both to hide  return crypto.timingSafeEqual(ab, bb);}
Verified by 3 asserts
JS
assert.equal(safeEqual('abc123', 'abc123'), true);assert.equal(safeEqual('abc123', 'abc124'), false);assert.equal(safeEqual('abc', 'abcd'), false);
04

Symmetric encryption: AES-256-GCM, random IV, auth tag (AEAD)#

JS
// VULNERABLE://   crypto.createCipheriv('aes-256-ecb', key, null)              // ECB: identical blocks => identical ciphertext//   crypto.createCipheriv('aes-256-cbc', key, Buffer.alloc(16))  // fixed IV; no integrity => padding oracle//   const KEY = 'supersecretkey1234567890abcdef12';              // hardcoded in repo// FIXED:const key = crypto.randomBytes(32);   // from KMS / env in real life, never a literalfunction encrypt(plaintext, k, aad = '') {  const iv = crypto.randomBytes(12);                             // 96-bit nonce, unique per message  const cipher = crypto.createCipheriv('aes-256-gcm', k, iv);  cipher.setAAD(Buffer.from(aad));  const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);  return { iv: iv.toString('base64'), ct: ct.toString('base64'), tag: cipher.getAuthTag().toString('base64') };}function decrypt({ iv, ct, tag }, k, aad = '') {  const decipher = crypto.createDecipheriv('aes-256-gcm', k, Buffer.from(iv, 'base64'));  decipher.setAAD(Buffer.from(aad));  decipher.setAuthTag(Buffer.from(tag, 'base64'));  return Buffer.concat([decipher.update(Buffer.from(ct, 'base64')), decipher.final()]).toString('utf8');}const msg = 'card=4111111111111111';const box = encrypt(msg, key, 'user:42');const tampered = { ...box, ct: Buffer.from(Buffer.from(box.ct, 'base64').map((b, i) => (i === 0 ? b ^ 1 : b))).toString('base64') };
Verified by 5 asserts
JS
assert.equal(decrypt(box, key, 'user:42'), msg);assert.notEqual(encrypt(msg, key).ct, encrypt(msg, key).ct);   // random IV => no pattern leakageassert.throws(() => decrypt(tampered, key, 'user:42'), /auth|Unsupported state/i);   // tag detects bit-flipassert.throws(() => decrypt(box, key, 'user:43'));                                   // AAD mismatchassert.throws(() => decrypt(box, crypto.randomBytes(32), 'user:42'));                // wrong key
05

HMAC for integrity (signed cookies, webhooks, tokens)#

JS
// VULNERABLE: cookie = base64(JSON({ uid: 1, role: 'admin' }))   // client edits role// FIXED:const hmacKey = crypto.randomBytes(32);function sign(payload, k) {  const body = Buffer.from(JSON.stringify(payload)).toString('base64url');  const mac = crypto.createHmac('sha256', k).update(body).digest('base64url');  return `${body}.${mac}`;}function verify(token, k) {  const [body, mac] = token.split('.');  if (!body || !mac) return null;  const expected = crypto.createHmac('sha256', k).update(body).digest('base64url');  if (!safeEqual(mac, expected)) return null;  return JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));}const tok = sign({ uid: 1, role: 'user' }, hmacKey);const forgedBody = Buffer.from(JSON.stringify({ uid: 1, role: 'admin' })).toString('base64url');
Verified by 3 asserts
JS
assert.deepEqual(verify(tok, hmacKey), { uid: 1, role: 'user' });assert.equal(verify(`${forgedBody}.${tok.split('.')[1]}`, hmacKey), null);assert.equal(verify(tok, crypto.randomBytes(32)), null);
06

JWT `alg: none` / algorithm confusion (shape of the check; use jose/jsonwebtoken in real code)#

JS
// VULNERABLE: jwt.verify(token, publicKey)  with no { algorithms: [...] }  -> attacker sets alg=HS256, signs with public key// FIXED: const payload = jwt.verify(token, key, { algorithms: ['RS256'] });   // allowlist; never trust header.algfunction headerAlgAllowed(token, allowed) {  const header = JSON.parse(Buffer.from(token.split('.')[0], 'base64url').toString('utf8'));  return allowed.includes(header.alg);}const noneTok = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url') + '.e30.';
Verified by 1 assert
JS
assert.equal(headerAlgAllowed(noneTok, ['RS256']), false);

See also