What Developers Still Get Wrong About Password Storage in 2024
LinkedIn stored 117 million passwords as unsalted SHA-1 β cracked within days. Adobe used 3DES encryption (reversible) instead of hashing. These are the specific, named password storage mistakes that keep producing data breaches. Here's every common error, why it fails, and the correct modern approach with bcrypt and Argon2.
By sadiqbd Β· June 16, 2026
In 2024, security researchers are still finding production databases with passwords stored in plain text β and it's not always small applications
The LinkedIn breach of 2012 exposed 117 million passwords stored as unsalted SHA-1 hashes. RockYou2024, published in 2024, compiled nearly 10 billion plaintext passwords from multiple breaches accumulated over years. Adobe (2013) stored passwords with 3DES encryption using the same key for all passwords β not even a hash. These aren't ancient history; they're the inevitable result of specific, well-understood, still-repeated mistakes.
Mistake 1: Plain-text storage
The most damaging mistake: storing passwords exactly as users type them.
# WRONG β never do this
def create_user(username, password):
db.execute("INSERT INTO users (username, password) VALUES (?, ?)",
username, password) # 'password' is the literal string
Why it still happens: tutorials that prioritise getting something working quickly; copy-pasted code from answers that omit security; developers who assume the database will never be breached; legacy codebases written before the developer knew better.
What attackers do with it: immediately use every credential. They don't need to crack anything. The breach-to-exploitation pipeline is instantaneous.
Mistake 2: Unsalted fast hashes (MD5, SHA-1, SHA-256)
# WRONG β fast hash without salt
import hashlib
password_hash = hashlib.md5(password.encode()).hexdigest()
Why MD5 and SHA-1 are wrong for passwords:
- They're designed to be fast β a GPU can compute billions per second
- Without salt, identical passwords produce identical hashes
- Pre-computed rainbow tables exist for billions of common passwords
The LinkedIn 2012 breach in numbers:
- 6.5 million (later revealed to be 117 million) unsalted SHA-1 hashes exposed
- Within days of the breach, 90%+ of the hashes were cracked
- Single GPU can compute approximately 10 billion SHA-1 hashes per second
- A dictionary of 1 billion common passwords takes approximately 1.7 minutes to exhaust
Mistake 3: Salted fast hashes
# STILL WRONG β salt helps but fast hash is still wrong
import hashlib, os
salt = os.urandom(16).hex()
password_hash = hashlib.sha256((salt + password).encode()).hexdigest()
# Stores salt + hash together
Why this is still wrong: the salt prevents rainbow tables and ensures different hashes for identical passwords β correct so far. But SHA-256 is still fast. At 10+ billion hashes per second on a GPU, a unique salt per user means the attacker must crack each hash independently, but each individual hash is still crackable quickly for short/common passwords.
An 8-character password from lowercase letters and digits has 36βΈ β 2.8 trillion possibilities. At 10 billion SHA-256 attempts per second: 2.8 trillion Γ· 10 billion = 280 seconds to exhaust the entire space. Most passwords are shorter, more common, and fall much faster.
The correct approach: purpose-built slow password hashing
Password hashing functions (PHFs) are deliberately slow β designed to be computationally expensive regardless of the attacker's hardware.
bcrypt:
import bcrypt
# Hashing (store the result)
password = b"user_password"
hashed = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))
# Verification
bcrypt.checkpw(password, hashed) # True
At cost factor 12: approximately 0.25 seconds per hash on modern server hardware. An attacker with a GPU gets approximately 25,000 attempts per second (not billions). An 8-character password space now takes: 2.8 trillion Γ· 25,000 = 112 million seconds β 3.5 years per user, per GPU.
Argon2 (the current recommended default):
from argon2 import PasswordHasher
ph = PasswordHasher(
time_cost=3, # number of iterations
memory_cost=65536, # 64 MB of memory
parallelism=4 # parallel threads
)
hashed = ph.hash("user_password")
ph.verify(hashed, "user_password") # True
Argon2 won the Password Hashing Competition (2015) and is the algorithm recommended by OWASP. Its memory cost parameter is particularly important: it forces attackers to use large amounts of memory per attempt, preventing the massively parallel GPU attacks that cost-factor-only algorithms face.
NIST SP 800-63B guidance (2017, updated 2024):
- Use a salt of at least 32 bits (generated by a CSPRNG)
- Use an approved password-based key derivation function: bcrypt, scrypt, Argon2, or PBKDF2 with SHA-256 at 600,000+ iterations (for FIPS compliance contexts)
- Do NOT use MD5, SHA-1, SHA-256/512 without a proper KDF structure
Mistake 4: Symmetric encryption instead of hashing
Adobe (2013) encrypted passwords with 3DES using a single key, rather than hashing them. This is wrong for two reasons:
- Encryption is reversible β if an attacker gets the key (along with the database), they decrypt every password instantly. Bcrypt is not reversible.
- Same password = same ciphertext when using ECB mode (which Adobe used) β the frequency analysis that defeated Caesar ciphers applies here too. Password hints exposed which accounts shared passwords.
The rule: password verification does not require storing a reversible value. You only need to check whether the candidate matches the stored hash β you never need to recover the original. Therefore: hash, not encrypt.
Mistake 5: Truncating passwords before hashing
Some implementations truncate long passwords to 72 characters (a bcrypt implementation detail) or even shorter lengths:
# WRONG β some implementations silently truncate
hashed = bcrypt.hashpw(password[:16].encode(), bcrypt.gensalt())
bcrypt itself truncates at 72 bytes (a property of the underlying Blowfish cipher). If you need to support arbitrarily long passwords: pre-hash with SHA-256, base64-encode the result, then feed that to bcrypt:
import bcrypt, hashlib, base64
def hash_password(password: str) -> bytes:
# Pre-hash to handle arbitrary length; base64 ensures no null bytes
pre_hashed = base64.b64encode(hashlib.sha256(password.encode()).digest())
return bcrypt.hashpw(pre_hashed, bcrypt.gensalt(rounds=12))
How to use the Hash Generator on sadiqbd.com
The Hash Generator produces MD5, SHA-1, SHA-256, and SHA-512 hashes. These are appropriate for:
- File integrity verification β hash a file before and after transfer; mismatched hashes indicate corruption or tampering
- Data deduplication β hash file contents to detect duplicates without byte-by-byte comparison
- Checksums for build artifacts β verify downloaded software against published checksums
- Non-security MACs and fingerprints β generating identifiers for content
They are not appropriate for:
- Storing passwords (use bcrypt/Argon2 via the Bcrypt Generator)
- Security-sensitive MACs without a key (use HMAC-SHA256 instead)
Frequently Asked Questions
Is PBKDF2 acceptable for password hashing? Yes, with sufficient iterations. NIST SP 800-63B recommends PBKDF2 with SHA-256 and a minimum of 600,000 iterations. PBKDF2 is FIPS 140-2 approved, making it required in US government contexts. For most applications, Argon2id is preferred because its memory cost provides better resistance to GPU attacks.
How do I migrate an existing MD5/SHA-1 password database to bcrypt? On next login: take the user's plaintext password (they just submitted it), bcrypt-hash it, store the new hash, and flag the account as migrated. Users who don't log in: require password reset. Never try to decrypt or re-hash old hashes in bulk without the original plaintext β the whole point is that you can't.
Is the Hash Generator free? Yes β completely free, no sign-up required.
Try the Hash Generator free at sadiqbd.com β generate MD5, SHA-1, SHA-256, and SHA-512 hashes for file integrity and data fingerprinting.