Home All Algorithms File Hash bcrypt Verify Hash Blog MD5 SHA-256 SHA-512 FAQ More Tools
Security

7 Password Hashing Mistakes That Get Companies Breached

📅 2026-05-12 ⏱ 8 min read ← Back to Blog

Every few months, a major breach reveals that a company that should have known better was storing passwords incorrectly. Sometimes the algorithm is wrong. Sometimes the algorithm is right but configured wrong. Sometimes the application around the hash function undoes the whole protection. Here are the patterns that keep recurring, with the lessons.

The mistakes, in one list

  1. Using a fast hash (MD5, SHA-1, SHA-256) for passwords
  2. Salting without a slow hash
  3. Hardcoded or per-application salts
  4. bcrypt at cost factor 4-6 (test settings in production)
  5. Pre-hashing with MD5 to "fit" into bcrypt's 72-byte limit
  6. Storing both old and new hash side-by-side ("password shucking")
  7. Implementing custom hash functions instead of using libraries

Mistake 1: Using a fast hash for passwords

Plain MD5, SHA-1, SHA-256, SHA-512 — none of these are password hash functions. They're designed to be fast. A modern GPU computes:

An 8-character lowercase password (200 billion possibilities) is exhausted in 8 seconds on MD5, 20 seconds on SHA-256. Even with a strong salt, the attacker still goes through each user's password at billions of guesses per second.

The fix: bcrypt, Argon2id, scrypt, or PBKDF2. These are designed to be slow — 100-500ms per hash, not nanoseconds. That's a ~10⁹× security multiplier.

Mistake 2: Salting without a slow hash

"We salt our hashes" is sometimes said with pride, as if salting alone fixed the password storage problem. It doesn't. Salts defeat rainbow tables — precomputed lookups. They do nothing to slow down per-password brute-force.

If you have sha256(salt + password) stored, an attacker who breaches your database still gets:

One user, 8-character lowercase password, ~20 seconds. Two users, ~40 seconds. A million users, 20 million seconds — but the attacker has thousands of GPUs.

The fix: salting and slowness must coexist. bcrypt, Argon2id, and scrypt do both automatically.

Mistake 3: Hardcoded or per-application salts

Found in real codebases:

function hash_password(pw) {
  return sha256("MyAwesomeApp_Salt_2019" + pw);
}

This is barely better than no salt at all. The attacker who breaches the database can:

  1. Read the source code (it's often on GitHub, or recoverable from compiled apps)
  2. Find the hardcoded salt
  3. Build a single rainbow table specific to this app
  4. Crack every user's password in parallel

Per-user random salts make this attack impossible. Each user requires their own independent brute-force.

The fix: use crypto.randomBytes(16) (or equivalent) to generate a fresh salt for every password. Or use bcrypt/Argon2id, which generate salts automatically.

Mistake 4: bcrypt at cost factor 4-6 in production

Cost factor 4 is bcrypt's "almost no work" setting. It's there for testing — when you're running 1,000 password tests in a unit suite and you don't want each test to take 200ms. bcrypt at cost 4 is ~1 millisecond per hash. Brute-forceable.

The mistake usually happens when:

  1. A developer sets bcrypt cost to 4 in development.env
  2. The setting is copied to production.env when the project goes live
  3. Nobody notices because login still works
  4. Three years later, a breach reveals everyone's bcrypt hashes at cost 4

The fix: hardcode the cost factor in your auth library, not in an environment variable. If you must use env, log a warning if the cost is below 10 at startup. OWASP recommends cost 12 for production.

Mistake 5: Pre-hashing with MD5 to fit bcrypt's 72-byte limit

bcrypt truncates input at 72 bytes. To support longer passphrases, some developers pre-hash with MD5 and feed the MD5 digest to bcrypt. The intent is reasonable; the implementation has a subtle problem.

MD5 outputs 16 raw bytes. When converted to ASCII (hex or base64), it fits within 72 bytes. But:

The fix: if you need >72-byte passwords, use Argon2id instead of bcrypt. Argon2id has no length limit. If you must stay on bcrypt, pre-hash with HMAC-SHA-256 (using a server-side secret key), not raw MD5 or SHA-256.

Mistake 6: Storing both old and new hashes side by side

Common pattern when migrating from MD5 to bcrypt:

users table:
  username      | md5_password         | bcrypt_password
  alice         | 5f4dcc3b...          | $2b$12$...
  bob           | 5f4dcc3b...          | NULL
  charlie       | NULL                 | $2b$12$...

The intent is "use bcrypt if available; fall back to md5 for unmigrated users." The problem: an attacker who breaches this database gets both. They crack alice's MD5 in seconds, then verify the same plaintext against bcrypt — proving the plaintext is correct even though bcrypt alone would have been too slow to crack.

Worse, "password shucking" can use just the MD5 column: given an MD5 hash and a known password (perhaps from another breach), the attacker can shuck it through bcrypt to find which user owns it.

The fix: delete the old hash immediately after upgrading. Don't keep both. If you need to support legacy users, use the wrap-and-upgrade pattern: store bcrypt(md5(password)) as a single value, and on next login compute md5(typed_password) first, then bcrypt-verify. Convert to direct bcrypt(password) as soon as you have the plaintext.

Mistake 7: Implementing custom hash functions

"We don't trust libraries, so we wrote our own SHA-256." This is wrong on multiple levels:

Real example: this site originally tried to implement bcrypt in pure JavaScript. The custom implementation passed 13 out of 14 OpenBSD test vectors. One test vector failed by 2 bytes. We couldn't find the bug. We switched to the canonical bcryptjs library.

The fix: use the library. Always. Even if you trust your code more than other people's — most production systems run on libraries that have been beaten on for 20+ years and survived. Yours hasn't.

The minimum viable password storage in 2026

If you do nothing else:

  1. Use Argon2id or bcrypt via the library, not your own code
  2. Argon2id: m=65536, t=3, p=1 minimum
  3. bcrypt: cost factor 12 minimum
  4. Let the library generate the salt
  5. Store only the resulting hash string (which includes the salt)
  6. On password change, generate a fresh hash (don't reuse salt)
  7. On framework/library upgrade, recheck the cost parameters annually

That's it. Seven items. The mistakes above happen when teams try to be clever or save time. Don't.

Try generating and verifying bcrypt hashes with our bcrypt tool — it uses the same proven bcryptjs library that backs many production auth systems. Great for understanding what proper password storage looks like in practice.

Our Network