Software Securityhome
Fix a findingSearch
Fix card · A04:2025 Cryptographic Failures

Fix weak password hashing in Node.js and Express

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Hash with Argon2id at or above OWASP's minimum: argon2.hash(password, { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 }). On login, compare legacy SHA-256 rows with crypto.timingSafeEqual and verify the rest with argon2.verify(). After a success, rehash when the row is legacy or argon2.needsRehash() says so, and save it with a compare-and-swap.

Stack
  • Language JavaScript (ES modules)
  • Runtime Node.js 26.11.1
  • Framework Express 5.3.0
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks7 cards
Download the project ZIP, 29 KB
On this page

Steps

  1. Find every place that hashes or compares passwords: createHash('sha256'), 'sha1' or 'md5' applied to a password, crypto.pbkdf2 with a low iteration count, bcrypt with a cost below 10, and === between a computed and a stored hash.
  2. Add argon2 (node-argon2) and hash new passwords with argon2.hash(password, POLICY), where POLICY names type argon2id, memoryCost in KiB, timeCost and parallelism. The library generates a 16-byte salt and returns a PHC string that records all of them.
  3. On login, recognise the old format first (here, 64 lowercase hex digits) and compare its raw 32-byte digest with crypto.timingSafeEqual; send everything else to argon2.verify(stored, password).
  4. Only after a successful verification, rehash when the row is legacy, is not $argon2id$, or argon2.needsRehash(stored, POLICY) is true, and save it with UPDATE users SET password_hash = ? WHERE id = ? AND password_hash = <the hash you verified>.
  5. When the username is unknown, verify against a dummy hash anyway and return the same 401 as for a wrong password, so neither the body nor the timing shows which accounts exist.
  6. Count the legacy rows that remain and decide what happens to them: expire them after a deadline and require a reset, or wrap them now as Argon2id(SHA-256(password)) behind a marker until their owners log in.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableimpl/vulnerable.jsJavaScript
export async function hashPassword(password) {
  return createHash('sha256').update(password, 'utf8').digest('hex');
}

With IMPL=vulnerable, 6 of 13 tests fail (5 exploit tests).

The fix: hashing policy

Fixedimpl/fixed.jsJavaScript
// OWASP minimum for Argon2id: 19 MiB (19456 KiB) of memory, 2 passes, 1 lane. Raise
// memoryCost and timeCost as far as your login latency and memory budget allow: every
// hash records its own parameters, so older hashes keep verifying.
const POLICY = { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 };

export function hashPassword(password) {
  return argon2.hash(password, POLICY);
}

With IMPL=fixed, 13 of 13 tests pass.

The fix: verify and upgrade

Fixedimpl/fixed.jsJavaScript
const LEGACY_SHA256 = /^[0-9a-f]{64}$/;

export async function verifyPassword(password, stored) {
  if (LEGACY_SHA256.test(stored)) {
    // A row from the old system: unsalted SHA-256. Compare in constant time, and ask
    // for a rehash so the next successful login replaces it.
    const digest = createHash('sha256').update(password, 'utf8').digest();
    const valid = timingSafeEqual(digest, Buffer.from(stored, 'hex'));
    return { valid, needsRehash: valid };
  }
  if (!stored.startsWith('$argon2')) return { valid: false, needsRehash: false };

  const valid = await argon2.verify(stored, password);
  // needsRehash() compares memory, passes, lanes and version with POLICY but not the
  // variant, so an $argon2i$ hash is checked separately.
  const outdated = !stored.startsWith('$argon2id$') || argon2.needsRehash(stored, POLICY);
  return { valid, needsRehash: valid && outdated };
}

With IMPL=fixed, 13 of 13 tests pass.

The fix: write the upgrade at login

Fixedapp.jsJavaScript
app.post('/login', async (req, res) => {
  const credentials = readCredentials(req.body);
  if (!credentials) return res.status(400).json(BAD_REQUEST);
  const user = db.prepare('SELECT id, password_hash FROM users WHERE username = ?').get(credentials.username);
  const result = await impl.verifyPassword(credentials.password, user?.password_hash ?? dummyHash);
  if (!user || !result.valid) return res.status(401).json({ error: 'invalid username or password' });

  if (result.needsRehash) {
    // Only now is the plaintext known to be right. Replace exactly the hash that was
    // verified: if a password change landed in the meantime, no row matches.
    const newHash = await impl.hashPassword(credentials.password);
    db.prepare('UPDATE users SET password_hash = ? WHERE id = ? AND password_hash = ?')
      .run(newHash, user.id, user.password_hash);
  }
  res.json({ username: credentials.username });
});

With IMPL=fixed, 13 of 13 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.jsJavaScript
export async function hashPassword(password) {
  const salt = randomBytes(16);
  const digest = createHash('sha256').update(salt).update(password, 'utf8').digest();
  return `sha256$${salt.toString('hex')}$${digest.toString('hex')}`;
}

With IMPL=wrong, 4 of 13 tests fail (3 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtest/passwords.test.jsJavaScript
describe(`exploit (IMPL=${implName})`, () => {
  test('a new hash is Argon2id at or above the OWASP minimum', async () => {
    assert.equal((await register('ivan', 'ivan-has-a-long-passphrase')).status, 201);
    assertMeetsPolicy(storedHash('ivan'));
  });

  test('the same password gives each user a different hash (per-user salt)', async () => {
    await register('judy', LEGACY_PASSWORD);
    await register('mallory', LEGACY_PASSWORD);
    assert.notEqual(storedHash('judy'), storedHash('mallory'));
  });

  test('a legacy unsalted SHA-256 hash is replaced after a successful login', async () => {
    const legacy = storedHash('alice');
    assert.equal((await login('alice', LEGACY_PASSWORD)).status, 200);
    assert.notEqual(storedHash('alice'), legacy);
    assertMeetsPolicy(storedHash('alice'));
    assert.equal((await login('alice', LEGACY_PASSWORD)).status, 200, 'the new hash verifies');
  });

  test('a wrong password is rejected and leaves the legacy hash alone', async () => {
    const legacy = storedHash('alice');
    assert.equal((await login('alice', 'correct horse battery stapler')).status, 401);
    assert.equal(storedHash('alice'), legacy);
  });

  test('a hash with weaker parameters is rehashed after a successful login', async () => {
    // 4 MiB and 3 passes: below every OWASP minimum configuration.
    const weak = await argon2.hash(LEGACY_PASSWORD, { type: argon2.argon2id, memoryCost: 4096, timeCost: 3, parallelism: 1 });
    db.prepare('UPDATE users SET password_hash = ? WHERE username = ?').run(weak, 'bob');
    assert.equal((await login('bob', LEGACY_PASSWORD)).status, 200);
    assertMeetsPolicy(storedHash('bob'));
  });

  test('the legacy hash comparison is constant-time (crypto.timingSafeEqual)', async (t) => {
    const spy = t.mock.method(crypto, 'timingSafeEqual');
    syncBuiltinESMExports(); // so `import { timingSafeEqual } from 'node:crypto'` sees the spy
    t.after(() => {
      spy.mock.restore();
      syncBuiltinESMExports();
    });
    assert.equal((await login('alice', LEGACY_PASSWORD)).status, 200);
    assert.ok(spy.mock.callCount() > 0, 'the SHA-256 comparison did not use crypto.timingSafeEqual');
  });
});

Pitfalls

  • argon2 0.45.0 and 0.45.1 write the parameters as m=,p=,t= instead of the m=,t=,p= order the PHC string format requires (node-argon2 issue #559, open on 2026-10-10). node-argon2 reads both, but argon2-cffi rejected such a hash with 'Decoding failed' and Spring Security's Argon2PasswordEncoder returned false. This card pins 0.44.0 and has a functional test for the order.
  • needsRehash(hash, options) fills missing options from the library defaults (64 MiB, t=3, p=4), so pass the same POLICY you hash with. It compares memory, passes, lanes and version only: an $argon2i$ hash with the same costs is not flagged, which is why fixed.js also checks the prefix. Any difference counts, so lowering the policy rehashes stronger hashes down to it.
  • argon2.verify() throws a TypeError for anything that is not a PHC string, such as a legacy hex hash, instead of returning false, so route legacy formats first. crypto.timingSafeEqual() throws a RangeError when the buffers differ in length, so compare fixed-length digests rather than strings.
  • node-argon2 runs each hash as N-API async work on libuv's thread pool (4 threads unless UV_THREADPOOL_SIZE says otherwise). That caps concurrent hashing, and its memory, at 4 x 19 MiB with this policy, but a burst of logins queues behind itself and behind fs, dns.lookup and zlib work on the same pool.
  • argon2.verify() hashes with the parameters stored in the hash. If anything other than this code can write the users table, a tampered row with m=4194304 makes one login allocate 4 GiB, so bound m and t before verifying.
  • bcrypt and bcryptjs use only the first 72 bytes of a password (bcryptjs offers truncates() to check), so two passwords that share those bytes verify as each other unless you reject longer input. Argon2 has no such limit, which the long-password test shows.
  • NIST SP 800-63B-4 recommends NFC normalization before hashing, while ASVS 5.0 (6.2.8) asks for the password to be verified exactly as received. This card hashes the UTF-8 bytes it receives; if you normalize, do it identically on every path, and remember that existing hashes were made without it.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs password-hashing/node-express does this for you).
  2. Look at real rows: accounts created or used since the change should hold $argon2id$v=19$m=19456,t=2,p=1$... (or stronger), and the number of rows still matching ^[0-9a-f]{64}$ should fall as people log in.
  3. Scanners do not catch this bug in Node today: the Semgrep rules look for MD5 or for argon2 loaded with require(), and found nothing here. CodeQL's js/insufficient-password-hash is the closest query (read, not run), so the regression tests are the control.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix weak password hashing in Node.js and Express
ToolRuleWhat it found here
Semgrepjavascript.lang.security.audit.md5-used-as-password.md5-used-as-passwordNo findings on this card: the rule's only source is createHash("md5"). With md5 swapped into impl/vulnerable.js it reported both functions, so the card's unsalted SHA-256 passes it unnoticed.Checked: Semgrep 1.180.0 (CE)
Semgrepjavascript.argon2.security.unsafe-argon2-config.unsafe-argon2-configNo findings, and it cannot check this card: it only matches code that loads argon2 with require(). An argon2i call was reported in a require() probe but not in the same code using an ES module import, and the rule checks the variant only, not memoryCost or timeCost.Checked: Semgrep 1.180.0 (CE)
CodeQLjs/insufficient-password-hashRead from the source, not run. Identifiers named like password are sources, and the argument of update() called directly on createHash() with an algorithm that is not a password hash is a sink: that is createHash('sha256').update(password) in impl/vulnerable.js and in the legacy branch of impl/fixed.js (expected there until the last legacy row is gone). Two caveats from the same reading: the model looks up the module 'crypto', and the API graph creates that node only when some file imports the bare name, while this card imports 'node:crypto'; and the second, chained update() in impl/wrong.js is not treated as the operation's input. Whether it reports this card needs a run.Checked: github/codeql main at b5b8165; not executed

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJavaScript (ES modules)
RuntimeNode.js 26.11.1
FrameworkExpress 5.3.0
argon2 (node-argon2)0.44.0 (reference Argon2 C code via N-API prebuilds)
express5.3.0
node:crypto (built in)Node.js 26.11.1
node:sqlite (built in)Node.js 26.11.1; SQLite 3.53.4 at run time

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the password-hashing/node-expressfolder.

Shell
npm ci
Shell
# Expected: every test passes
IMPL=fixed npm test --silent

# Expected: an exploit test fails
IMPL=vulnerable npm test --silent

# Expected: a test fails
IMPL=wrong npm test --silent

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs password-hashing/node-express runs all three modes and checks the expected results.

Download the project ZIP, 29 KB

Verified

Machine
Apple M5 Max, macOS 27.0
Toolchain
  • node 26.11.1
  • npm 11.20.0
Matrix
fixed: 13 of 13 tests pass; vulnerable: 6 of 13 tests fail (5 exploit tests); wrong: 4 of 13 tests fail (3 exploit tests)
Test results
Offline run of run-all.mjs (outbound network blocked): fixed 13/13 pass; vulnerable 6/13 fail (5 exploit); wrong 4/13 fail (3 exploit)
Hash cost
17.0 ms per Argon2id hash at m=19456, t=2, p=1 (mean of 20 after warm-up)

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP Password Storage Cheat Sheet
  2. RFC 9106: Argon2 (parameter choice)
  3. node-argon2 README
  4. node-argon2 issue #559: Order of parameters is incorrect
  5. C2SP: PHC string format (Argon2 parameter order)
  6. Node.js v26 crypto.timingSafeEqual()
  7. Node.js v26 UV_THREADPOOL_SIZE
  8. NIST SP 800-63B-4: Password verifiers
  9. bcryptjs README (72-byte input limit, truncates())
  10. npm approve-scripts (allowScripts in package.json)

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.