The wrong cryptography works perfectly well
A cryptographic error crashes nothing. The text gets encrypted, it gets decrypted, the test passes, the user logs in: what is missing is the security property, and there is no error message to say so. That is why the OWASP Top 10:2025 has a dedicated category, A04:2025 Cryptographic Failures, and its verification questions are almost all "what did you use, and how?": initialization vectors ignored or reused, an insecure mode such as ECB, passwords used as keys without a derivation function, random numbers not designed for cryptographic purposes.
In this article each error is reproduced on test data and then fixed, with proof that the fix holds. Everything runs with the node:crypto module of Node v24.11.0, with no external dependencies, no network and no server; every output shown is identical across two consecutive runs (where a value depends on the machine, the output compares thresholds and says so). There is nothing to install: if you have Node 24.7 or later, you can rerun every listing. There are four errors: the encryption mode, the initialization vector, the password hash function and the quality of tokens.
ECB: equal blocks stay equal
ECB mode encrypts each 16-byte block separately with the same key. The NIST SP 800-38A specification (2001 edition) says so: with a given key, a given block of plaintext is always encrypted to the same block of ciphertext, and if this property is undesirable ECB should not be used. In the experiment the text has four blocks, of which the first and the third are identical, like two equal rows of a salary file. The program counts the duplicate ciphertext blocks with AES-256 in ECB and with AES-256-GCM.
import crypto from 'node:crypto';
const key = crypto.randomBytes(32);
// 4 blocks of 16 bytes: the first and the third are identical, like two equal rows in an encrypted file
const block = (s) => Buffer.from(s.padEnd(16, '.'));
const plaintext = Buffer.concat([block('SALARY: 1000'), block('SALARY: 2500'), block('SALARY: 1000'), block('END')]);
const duplicateBlocks = (buf) => {
const seen = new Set();
let dup = 0;
for (let i = 0; i < buf.length; i += 16) {
const h = buf.subarray(i, i + 16).toString('hex');
if (seen.has(h)) dup++; else seen.add(h);
}
return dup;
};
// ECB: each block is encrypted on its own, so equal blocks produce equal ciphertexts
const ecb = crypto.createCipheriv('aes-256-ecb', key, null);
const ecbOut = Buffer.concat([ecb.update(plaintext), ecb.final()]);
// GCM with a random 12-byte IV: the correct scheme
const iv = crypto.randomBytes(12);
const gcm = crypto.createCipheriv('aes-256-gcm', key, iv);
const gcmOut = Buffer.concat([gcm.update(plaintext), gcm.final()]);
console.log('duplicate blocks in the plaintext :', duplicateBlocks(plaintext));
console.log('duplicate blocks in the ECB output :', duplicateBlocks(ecbOut));
console.log('duplicate blocks in the GCM output :', duplicateBlocks(gcmOut));
// Output:
// duplicate blocks in the plaintext : 1
// duplicate blocks in the ECB output : 1
// duplicate blocks in the GCM output : 0
In the plaintext there is one duplicate block; in the ECB ciphertext the duplicate is still there, so whoever observes the ciphertext sees where the data repeats without knowing the key. In the GCM ciphertext the duplicates are zero. The detail not to miss in the listing is createCipheriv('aes-256-ecb', key, null): ECB takes no IV, and the very absence of the IV is the defect. If a library lets you pick the cipher with a short name such as aes-256, read in its documentation which mode it applies and always write it out in full in the code: counting duplicate blocks, as here, is a test that can be automated in two lines.
GCM: a repeated IV reveals the messages
AES-GCM is the right choice for encrypting application data because it encrypts and authenticates together. It has a condition that is not negotiable, and the standard states it plainly: NIST SP 800-38D (November 2007), §8, requires that the probability of invoking the encryption with the same IV and the same key on two distinct sets of data not exceed 2-32. §8.3 adds that with randomly generated IVs the total number of invocations with one key must not exceed 232. In practice: 12-byte IVs taken from crypto.randomBytes and a key that is changed well before that number.
The listing shows what happens when the rule is violated. With the same IV, GCM produces the same keystream for the two messages, so the XOR of the two ciphertexts is the XOR of the two plaintexts: if the attacker knows or guesses one of the two (a courtesy opening, a fixed format), he derives the other without the key. Here the known text is the opening of a message and the secret is a PIN. Then the correct block encrypts with a new IV for every message and also demonstrates the other half of GCM's advantage: a single altered bit in the ciphertext makes decryption fail.
import crypto from 'node:crypto';
const key = crypto.randomBytes(32);
const encrypt = (iv, text) => {
const c = crypto.createCipheriv('aes-256-gcm', key, iv);
return { ct: Buffer.concat([c.update(text), c.final()]), tag: c.getAuthTag() };
};
const xor = (a, b) => Buffer.from(a.map((x, i) => x ^ b[i]));
// ERROR: the same IV used for two messages with the same key
const fixedIv = Buffer.alloc(12, 7);
const known = Buffer.from('Dear customer, your PIN code is'); // text the attacker knows or guesses
const secret = Buffer.from('PIN: 4921, valid for 10 minutes'); // same length, the secret
const a = encrypt(fixedIv, known), b = encrypt(fixedIv, secret);
// Without knowing the key: ct_a XOR ct_b = known XOR secret, and therefore known XOR (ct_a XOR ct_b) = secret
const recovered = xor(known, xor(a.ct, b.ct));
console.log('repeated IV, secret recovered :', recovered.toString());
// CORRECT: a new random IV for every message, carried together with the ciphertext
const sealed = (text) => {
const iv = crypto.randomBytes(12);
const { ct, tag } = encrypt(iv, text);
return { iv, ct, tag };
};
const m = sealed(secret);
const open = ({ iv, ct, tag }) => {
const d = crypto.createDecipheriv('aes-256-gcm', key, iv);
d.setAuthTag(tag);
return Buffer.concat([d.update(ct), d.final()]).toString();
};
console.log('random IV, decrypted by the owner:', open(m));
// Authentication: a single flipped bit in the ciphertext and GCM rejects the message
const tampered = { ...m, ct: Buffer.from(m.ct) };
tampered.ct[0] ^= 1;
try { open(tampered); } catch (e) { console.log('tampered ciphertext :', e.message); }
// The same text encrypted twice with random IVs gives two different ciphertexts
console.log('same text, two equal encryptions?', sealed(secret).ct.equals(sealed(secret).ct));
// Output:
// repeated IV, secret recovered : PIN: 4921, valid for 10 minutes
// random IV, decrypted by the owner: PIN: 4921, valid for 10 minutes
// tampered ciphertext : Unsupported state or unable to authenticate data
// same text, two equal encryptions? false
The first line of the output is the damage: the PIN is recovered in the clear, without using the key. The other three are the fix: the owner decrypts, the altered ciphertext is rejected with Unsupported state or unable to authenticate data (the error text comes from Node, and changes with the version), and two encryptions of the same text are different. In the code the IV travels together with the ciphertext and the tag, because it is not a secret: it only has to never repeat. It should be remembered that the attack with a repeated IV, according to the specification, concerns authentication as well: §8 speaks of forgery attacks. If you have already encrypted data with a constant IV, the remedy is to change the key and re-encrypt.
Passwords: SHA-256 is fast, and that is the problem
The OWASP Password Storage Cheat Sheet says two things that the listing verifies. The first: you need a unique salt for every password, to prevent precomputed tables. The second: fast hashes such as SHA-256 are not suitable, because they allow the attacker an enormous number of attempts. In the experiment a "stolen" store contains unsalted SHA-256 of dictionary passwords: Anna and Carla chose the same password and you can see it from the hash, and a table of five words recovers them all. Then the same material is stored with Argon2id.
For Argon2id the OWASP sheet gives as minimum configuration 19 MiB of memory, 2 iterations and parallelism 1. Node has crypto.argon2Sync since version 24.7.0; the memory parameter is expressed in blocks of 1 KiB, so 19 MiB is 19456, and nonce (the salt) must be at least 8 bytes long. The comparison of the result uses crypto.timingSafeEqual, which is suitable because tagLength fixes the length of the tag.
import crypto from 'node:crypto';
// "Stolen" store with unsalted SHA-256. The users chose dictionary passwords.
const sha = (p) => crypto.createHash('sha256').update(p).digest('hex');
const users = { anna: sha('sunshine'), bruno: sha('qwerty123'), carla: sha('sunshine') };
console.log('do anna and carla have the same hash?', users.anna === users.carla);
const dictionary = ['password', 'sunshine', 'qwerty123', '123456', 'letmein'];
const table = new Map(dictionary.map((w) => [sha(w), w])); // precomputed table
for (const [u, h] of Object.entries(users)) console.log(` ${u}: ${table.get(h) ?? 'not found'}`);
// Argon2id (Node >= 24.7): minimum parameters given by the OWASP Password Storage Cheat Sheet:
// 19 MiB of memory (19456 blocks of 1 KiB), 2 passes, parallelism 1
const params = { memory: 19456, passes: 2, parallelism: 1, tagLength: 32 };
const hashPassword = (pw) => {
const nonce = crypto.randomBytes(16); // random salt, different for every user
const tag = crypto.argon2Sync('argon2id', { message: pw, nonce, ...params });
return { nonce, tag };
};
const verify = (pw, rec) => {
const tag = crypto.argon2Sync('argon2id', { message: pw, nonce: rec.nonce, ...params });
return crypto.timingSafeEqual(tag, rec.tag); // same length guaranteed: fixed tagLength
};
const a = hashPassword('sunshine'), c = hashPassword('sunshine');
console.log('Argon2id, same password: equal hashes?', a.tag.equals(c.tag));
console.log('correct / wrong verification:', verify('sunshine', a), verify('sunshine1', a));
// Cost of a guess: how many verifications per second for an attacker trying passwords in sequence
const rate = (fn, ms) => { let n = 0; const end = performance.now() + ms; while (performance.now() < end) { fn(n++); } return Math.round(n / (ms / 1000)); };
const fast = rate((i) => sha('guess' + i), 300);
const slow = rate((i) => crypto.argon2Sync('argon2id', { message: 'guess' + i, nonce: a.nonce, ...params }), 1000);
// The exact value changes from machine to machine and from run to run: wide thresholds are compared
console.log('SHA-256 above 50,000 guesses/s on one thread :', fast > 50_000);
console.log('Argon2id below 200 guesses/s on one thread :', slow < 200);
// Output:
// do anna and carla have the same hash? true
// anna: sunshine
// bruno: qwerty123
// carla: sunshine
// Argon2id, same password: equal hashes? false
// correct / wrong verification: true false
// SHA-256 above 50,000 guesses/s on one thread : true
// Argon2id below 200 guesses/s on one thread : true
The first block of lines is the weak store: identical hashes for identical passwords, and all three passwords found in a table. With Argon2id the same passwords produce different hashes because the salt is different, and the verification accepts the right password and rejects the wrong one. The last two lines compare the cost of an attempt: SHA-256 exceeds 50,000 attempts per second on a single thread, Argon2id with these parameters stays below 200. They are wide thresholds, not measurements: the exact number varies from machine to machine and from run to run, which is why I do not report it. The consequence is however the reason for everything: every attempt that costs the server memory and time costs the same to whoever attacks.
If Argon2id is not available, the cheat sheet gives as alternatives scrypt with minimum cost 217, block size 8 and parallelism 1; for systems that require FIPS-140 compliance it gives PBKDF2 with at least 600,000 iterations and HMAC-SHA-256; for bcrypt a work factor of at least 10 and a limit of 72 bytes on the password. It is better to choose the algorithm before writing the login code, because migrating existing hashes requires rehashing at each user's first login.
Tokens: random does not mean "hard for me to guess"
The third error is in the way reset or invitation tokens are generated. A token built from the email and the time, passed through a hash, looks random but is deterministic: whoever knows the email and the approximate time, which the HTTP response reveals in the Date header, can reproduce it. This is the case of CWE-330, "Use of Insufficiently Random Values", and of CWE-338 if the generator is non-cryptographic. The experiment uses a fixed instant to be repeatable and leaves the "attacker" a window of plus or minus two seconds.
import crypto from 'node:crypto';
// ERROR: password reset token derived from data the attacker can reconstruct
const weakToken = (email, nowMs) => crypto.createHash('sha256').update(`${email}:${nowMs}`).digest('hex').slice(0, 32);
const victim = 'anna@example.test';
const issuedAt = 1_800_000_000_123; // issue instant (fixed to make the test repeatable)
const stolenFromEmail = weakToken(victim, issuedAt); // the token that will end up in Anna's email
// The attacker knows the email and, from the response's Date header, the time within +-2 seconds
let tries = 0, found = null;
for (let t = issuedAt - 2000; t <= issuedAt + 2000 && !found; t++) {
tries++;
if (weakToken(victim, t) === stolenFromEmail) found = t;
}
console.log('predictable token: found after', tries, 'attempts, instant', found === issuedAt ? 'correct' : 'wrong');
// CORRECT: 32 bytes from a cryptographic generator, 256 bits of entropy, no relation to the time
const strongToken = () => crypto.randomBytes(32).toString('base64url');
console.log('random token:', strongToken().length, 'characters, two issues equal?', strongToken() === strongToken());
// Signed token (HMAC) and its verification. timingSafeEqual throws an exception if the lengths differ
const secret = crypto.randomBytes(32);
const sign = (payload) => crypto.createHmac('sha256', secret).update(payload).digest();
const verifyNaive = (payload, mac) => crypto.timingSafeEqual(sign(payload), mac);
try { verifyNaive('uid=7', Buffer.from('too-short')); } catch (e) { console.log('different length without a check:', e.code); }
const verifySafe = (payload, mac) => {
const expected = sign(payload);
return mac.length === expected.length && crypto.timingSafeEqual(expected, mac);
};
console.log('right MAC :', verifySafe('uid=7', sign('uid=7')));
console.log('wrong MAC:', verifySafe('uid=7', sign('uid=8')));
console.log('MAC of wrong length:', verifySafe('uid=7', Buffer.from('x')));
// Output:
// predictable token: found after 2001 attempts, instant correct
// random token: 43 characters, two issues equal? false
// different length without a check: ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH
// right MAC : true
// wrong MAC: false
// MAC of wrong length: false
The predictable token falls in 2001 attempts: the true instant sits in the middle of the 4-second window, 2000 milliseconds after its start, so the search stops at the 2001st candidate: a job of a few milliseconds. The token from randomBytes(32) is 43 characters long in base64url and has no relation to the time. The final part of the listing concerns the verification of a MAC: timingSafeEqual compares in constant time (against CWE-208, "Observable Timing Discrepancy"), but throws ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH if the two lengths differ. Whoever omits the length check gets an unhandled exception (typically a 500) instead of a rejection, and that difference in behavior tells the attacker that the format is wrong. Three responses (right, wrong, wrong length) must always produce the same rejection outcome.
Three words that get confused, and a checklist
- Hash: one-way, used to verify (passwords, integrity), with a cost you choose
- Encryption: reversible with a key, used to safeguard data you will read later
- Encoding (base64, hex): reversible by anyone, no protection
The three items have no order of importance and are constantly confused: a password that should have been hashed gets encrypted, so whoever steals the key reads them all; a base64 token is called "encrypted" when anyone can decode it. Before writing a line of cryptography it is worth asking the question that separates them: do you need to be able to recover the original data, or only to verify that whoever presents it knows it?
Left out of this experiment are key management (CWE-321, "Use of Hard-coded Cryptographic Key", avoided with what Secret management and credential rotation explains), TLS configuration, the browser's Web Crypto API, RSA and EC signatures and post-quantum cryptography: topics to be treated with their own sources, not with a passing mention. JWTs, where the same errors (weak keys, algorithm chosen by the token) have a direct effect, are in API Security: OAuth 2.1, JWT and rate limiting; sessions and cookies, in Secure authentication; the previous article, Supply chain security, concerns the libraries you will use for all this, and the next, DevSecOps for developers, how to have the pipeline verify these rules.
What you can do now
You can recognize in a review the four errors that give no symptoms: ECB, a reused IV, a fast hash for passwords, a token derived from known data. You can encrypt with AES-256-GCM and a random IV and recognize an altered ciphertext, store a password with Argon2id using OWASP's minimum parameters and verify it with a constant-time comparison, generate a token with randomBytes. You can also write for each a test that fails on the wrong code, and that is the part that can be automated in the pipeline.
Frequently asked questions
Why is SHA-256 not used to store passwords?
Because it is designed to be fast, and an attacker with the store can try enormous numbers of passwords per second. OWASP recommends Argon2id, or alternatively scrypt, bcrypt or PBKDF2, all with an adjustable cost and a salt per user.
What happens if I reuse the IV in AES-GCM?
The confidentiality of messages encrypted with that key is compromised: the XOR of the two ciphertexts is the XOR of the two plaintexts, and NIST SP 800-38D warns that the violation also exposes to forgery attacks. The IV must be unique per key.
Why is AES-ECB considered insecure?
Because the same block of text always produces the same block of ciphertext with the same key, so the structure of the data stays visible. NIST SP 800-38A says so explicitly.
How do you generate a secure reset token?
With a cryptographic generator, for example crypto.randomBytes(32) in Node, without deriving it from email or time. You store its hash on the server with a short expiry and invalidate it on first use.
The same series also includes OWASP Top 10 2025, XSS, CSRF and CSP and SQL injection and input validation.
Sources
- OWASP Top 10:2025, A04 Cryptographic Failures - the verification questions on IVs, ECB, passwords as keys and randomness (page read), consulted on 2026-11-13
- NIST SP 800-38A: Recommendation for Block Cipher Modes of Operation (2001) - the property of ECB and the warning on its use (text read), consulted on 2026-11-13
- NIST SP 800-38D: Galois/Counter Mode and GMAC (November 2007) - §8 IV uniqueness (probability not above 2-32) and §8.3 limit of 232 invocations with random IVs (text read), consulted on 2026-11-13
- OWASP Password Storage Cheat Sheet - unique salt, rejection of fast hashes and minimum parameters of Argon2id, scrypt, bcrypt and PBKDF2 (page read), consulted on 2026-11-13
- Node.js v24 API: crypto - signature and parameters of crypto.argon2Sync, added in v24.7.0, consulted on 2026-11-13
- CWE-330: Use of Insufficiently Random Values - the official name of the weakness of predictable tokens; CWE-338, 208 and 321 cited in the text are analogous, consulted on 2026-11-13
The four listings were each run twice with identical output; they are teaching experiments on invented data and do not replace review by an expert. Not run or covered: TLS, Web Crypto, asymmetric signatures, post-quantum.

Commenti
Caricamento commenti...
Sign in to comment