Insecure Password Hashing (CWE-916): Why SHA-256 Is Not Enough and How to Store Passwords
Storing passwords as unsalted SHA-256, SHA-1 or MD5 digests means a leaked table can be tested at billions of guesses per second on a GPU, and every user with the same password shares one hash. How salts, memory-hard functions such as Argon2id, rehash-on-login and dummy verification protect a stolen database, with a Python argon2-cffi example.
Plain English Explainer (ELI5)
A cloakroom keeps a copy of every ticket so it can check who owns which coat. If the copies are plain photocopies, a thief who steals the folder can walk out with every coat. A fast hash is a photocopy that has been lightly smudged: a thief with a quick scanner can unsmudge millions a second. A slow, memory-hungry hash is a copy locked in a small safe that takes a full second and a large workbench to open, one safe per ticket, each with its own different lock. The owner opens one safe at the door without noticing the delay; the thief has to open millions.
Core Concepts & Subsystem Terms
- Fast hash
- SHA-256, SHA-1 and MD5 are built to be fast. A single modern GPU computes billions of SHA-256 hashes per second, so short or common passwords fall to brute force and dictionary attacks within hours.
- Salt
- A random value stored with each hash so identical passwords get different hashes. Without it, one precomputed table cracks every account at once and equal hashes reveal users who share a password.
- Memory-hard function (Argon2id)
- Argon2id makes every guess cost both time and memory, which removes most of a GPU's advantage. OWASP's minimum is 19 MiB of memory, two iterations and one lane (
memory_cost=19456,time_cost=2,parallelism=1); scrypt and bcrypt are acceptable alternatives. - PHC string and rehash
PasswordHasher.hash()returns a string such as$argon2id$v=19$m=19456,t=2,p=1$...holding the algorithm, parameters, salt and hash.check_needs_rehash()tells you when stored parameters are weaker than the current ones, so the hash can be upgraded at the next successful login.- Timing and account enumeration
- If unknown emails return instantly while real ones take the time of a hash check, response times reveal which accounts exist. Verifying against a dummy hash makes both paths take the same time.
Step-by-Step Attack Flow
The user table leaks
A SQL injection, an exposed backup or a stolen laptop gives the attacker the password_hash column.
The attacker runs a GPU cracker
Unsalted SHA-256 lets them hash a wordlist once and compare it against every row. A test account with the password Summer2024! shows how quickly a common pattern falls.
Shared passwords fall together
Every user who picked the same password has the same digest, so one cracked hash exposes all of them.
Passwords are reused elsewhere
Cracked passwords are tried on email, banking and cloud accounts. In 2012 LinkedIn lost about 6.5 million unsalted SHA-1 password hashes, and in 2016 a set of about 117 million credentials from the same breach went on sale.
Source Code: Flaw vs. Secure Implementation
# users.py: one fast, unsalted digest per password
import hashlib
from .db import db
# same password, same hash, billions of guesses per second on a GPU
def create_user(email: str, password: str) -> None:
digest = hashlib.sha256(password.encode()).hexdigest()
db.execute("INSERT INTO users (email, password_hash) VALUES (?, ?)", (email, digest))
# unknown emails return instantly, so timing reveals which accounts exist
def check_login(email: str, password: str) -> bool:
row = db.execute("SELECT password_hash FROM users WHERE email = ?", (email,)).fetchone()
return row is not None and row[0] == hashlib.sha256(password.encode()).hexdigest()
# users.py: Argon2id with a per-password salt and upgrade on login
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError
from .db import db
# OWASP minimum for Argon2id: 19 MiB, 2 iterations, 1 lane
hasher = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
DUMMY_HASH = hasher.hash("timing-equaliser")
# the stored string carries algorithm, parameters, salt and hash
def create_user(email: str, password: str) -> None:
if not 12 <= len(password) <= 128:
raise ValueError("password must be 12 to 128 characters")
db.execute("INSERT INTO users (email, password_hash) VALUES (?, ?)", (email, hasher.hash(password)))
# unknown users still pay for one verification; weak hashes are upgraded
def check_login(email: str, password: str) -> bool:
row = db.execute("SELECT id, password_hash FROM users WHERE email = ?", (email,)).fetchone()
stored = row[1] if row else DUMMY_HASH
try:
hasher.verify(stored, password)
except (VerificationError, InvalidHashError):
return False
if row is None:
return False
if hasher.check_needs_rehash(stored):
db.execute("UPDATE users SET password_hash = ? WHERE id = ?", (hasher.hash(password), row[0]))
return True
Engineering & System Hardening Checklist
- Hash passwords with Argon2id (at least 19 MiB memory, 2 iterations, 1 lane), scrypt or bcrypt; add a Semgrep rule that flags
hashlibcalls on password variables and run Bandit B324 for MD5 and SHA-1. - Store the complete PHC string the library returns, and never split out or reuse the salt.
- Call
check_needs_rehash()after each successful login and replace hashes created with weaker parameters. - Verify against a dummy hash when the account does not exist, so response time does not reveal registered emails.
- Require at least 12 characters, allow long passphrases up to at least 64, and reject passwords found in breach lists.