CWE-916: Use of Password Hash With Insufficient Computational Effort
Overview
Weak password hashing occurs when an application stores passwords using a fast general-purpose hash function (MD5, SHA-1, SHA-256) or a password hashing algorithm configured with too little work. Password hashing needs a deliberately slow, expensive algorithm, because the attacker's advantage here is speed: once a database dump is in hand, a fast hash can be cracked back to the plaintext password, which users often reuse across other sites.
Relationship to Other CWEs
CWE-916 is the parent of two narrower salting defects: CWE-759 (Use of a One-Way Hash without a Salt) covers the case where no salt is used at all, and CWE-760 (Use of a One-Way Hash with a Predictable Salt) covers a salt that is present but guessable, shared, or derived from a predictable value. CWE-916 is the broader weakness of using a hashing scheme, salted or not, with insufficient computational effort against offline cracking.
OWASP Classification
A04:2025 - Cryptographic Failures
Risk
High: An attacker who obtains the password table cracks it offline, at their own pace. Every password recovered opens the account it belongs to - administrative accounts included - and, because users reuse passwords, is then worth trying against other services. Compliance exposure depends on the regime: PCI DSS requires strong one-way cryptography for stored authentication credentials outright, while HIPAA and GDPR Article 32 are risk-based, so there the finding is failing a control your own assessment called for.
Modern GPUs compute billions of SHA-256 hashes per second, so a dictionary run against millions of common passwords finishes in seconds, and that work is amortized across every dump the attacker holds. Short, common, and reused passwords fall first. Rainbow tables belong to a different weakness - they are defeated by a unique per-password salt, not by slowness, and so are covered by CWE-759 and CWE-760 rather than by this one.
Remediation Steps
Core Principle: Password hashing must use adaptive, purpose-built algorithms (Argon2id/bcrypt/scrypt) with work factors tuned to current hardware - slow enough to make brute force attacks impractical (target 250-500ms per hash) while remaining fast enough for legitimate authentication. Never use fast general-purpose hashes (MD5, SHA-1, SHA-256) for passwords.
Locate the weak password hashing
- Review the flaw details for the file, line number, and code pattern
- Identify which weak hashing algorithm is in use (MD5, SHA-1, SHA-256, unsalted hash, etc.)
- Trace the password flow from registration and login through hashing to storage
- Check the database schema: the password hash column, and whether a salt column exists
Replace with adaptive password hashing algorithms (Primary Defense)
Replace fast hashes with purpose-built password hashing functions. This is OWASP's order, with the reason each sits where it does:
1. Argon2id - winner of the 2015 Password Hashing Competition and OWASP's first choice for new systems, because it resists GPU cracking and side-channel attacks together rather than trading one for the other
- Resistant to GPU and ASIC attacks: memory-hard as well as CPU-hard
- OWASP publishes five equivalent configurations, trading memory against time. Pick the one your hardware affords, not the one that looks strongest: m=47104 KiB (46 MiB) t=1 p=1, m=19456 KiB (19 MiB) t=2 p=1, m=12288 KiB (12 MiB) t=3 p=1, m=9216 KiB (9 MiB) t=4 p=1, m=7168 KiB (7 MiB) t=5 p=1
- These are the floor, not a ladder - the largest memory setting pairs with a single iteration, and raising both m and t together is what actually increases cost
2. scrypt - OWASP's choice where Argon2id is not available, since it is also memory-hard and so buys the same resistance to parallel cracking hardware
- Configuration: N=2^17, r=8, p=1 minimum
3. bcrypt - OWASP scopes it to legacy systems where neither Argon2id nor scrypt is available. It is CPU-hard but not memory-hard, and it carries the input-length trap below, so it is not what a new system should be built on
- Work factor: tune on production-class hardware; cost 12+ is a common baseline
- Generates and stores its own salt
- bcrypt hashes at most the first 72 bytes of the input, and many implementations truncate silently rather than raising. Verified on PHP 8.5:
password_verify()returns true for a password differing from the stored one only after byte 72, so two distinct passwords authenticate interchangeably. Python'sbcryptraisesValueErrorinstead - the behaviour is library-dependent, so do not rely on it - Either cap password length at 72 bytes in input validation, or pre-hash. If pre-hashing, base64-encode the digest first: raw binary digest output can contain a NUL byte, which truncates the bcrypt input at that point. OWASP's form is
bcrypt(base64(hmac-sha384(password, pepper)), salt, cost), and the choice of SHA-384 is load-bearing: its 48-byte output is 64 base64 characters, inside the limit, while a 64-byte SHA-512 output is 88 and is not
4. PBKDF2 - the choice where FIPS-140 validation is required, since it is the only one of the four with widely available validated implementations
- Iteration counts are per-PRF and are not interchangeable: PBKDF2-HMAC-SHA256 600,000; PBKDF2-HMAC-SHA512 220,000; PBKDF2-HMAC-SHA1 1,400,000 and legacy use only
- There is no second, higher number for "high security" - above the baseline the parameter is a latency budget. Measure on production-class hardware and raise iterations until a hash costs what you are willing to spend on every login
AVOID: MD5, SHA-1, SHA-256, SHA-512 - all too fast - and any hand-rolled "salted hash" built on top of them.
Configure adequate work factors and implement proper salting
- Target 250-500ms per hash, measured on production hardware rather than a development machine. Raising the work factor later applies to new hashes only - the plaintext needed to re-hash an existing one is gone, so each stored password is upgraded at that user's next successful login, exactly like the algorithm migration below
- Generate a new random salt for every password, 128 bits minimum, from a cryptographically secure RNG, and store it alongside the hash
- The bcrypt, Argon2 and scrypt libraries do the salting themselves, so there is no manual salt handling to get wrong
Implement migration strategy for existing password hashes
The plaintext needed to re-hash a stored password is gone, so the estate cannot simply be converted. Batch re-hash every stored digest under the new algorithm instead - bcrypt(md5($password)) for an MD5 estate - so that no fast hash is left in the database waiting for its owner to log in, then reach the clean form as users authenticate. Record which form each row is in, verify a login against both the layered and the clean form, and drop support for the layered one once the estate has moved over.
Migration Approach below has the full sequence, along with the digest-length and salt caveats that decide whether the batch works.
Monitor and audit password hashing usage
- Log password hashing operations (registration, login, password change)
- Alert on usage of deprecated algorithms while the layered form is still accepted
- Track migration progress with database queries (SELECT hash_version, COUNT(*) FROM users GROUP BY hash_version)
- Review code for remaining instances of weak hashing (grep for MD5, SHA256, etc.)
- Monitor hash computation time to ensure work factor is adequate
Test the password hashing changes
- Time a single hash on production-class hardware and confirm it lands inside the 250-500ms target
- Register a new account and confirm the stored hash is in the new format and that logging in with it succeeds
- Re-scan with the security scanner to confirm the finding is resolved
- Migration-specific testing is in Testing Recommendations below
Migration Considerations
Changing the password hashing algorithm stops users from logging in with their existing passwords unless the stored hashes are migrated alongside the code.
What Breaks
An existing hash was produced by the old algorithm, so verifying it under the new one fails and a login with the correct password is rejected. Without a migration path the only way back into an account is the email password-reset flow, and support absorbs the resulting tickets.
Migration Approach
Layer first, then upgrade on login (Recommended)
Dual-read alone leaves every un-migrated MD5 or SHA-256 digest sitting in the database for the whole migration window, and a database dump does not wait for users to log in. Close that exposure immediately by re-hashing the stored digests under the new algorithm in a single batch - bcrypt(md5($password)) for an MD5 estate - then reach the clean form as users authenticate. OWASP notes the caveat: a layered hash is somewhat easier to crack than one taken directly from the password, so it is a transitional state rather than the destination.
Two details decide whether the batch works. The first is the length of what you feed bcrypt, measured against the 72-byte limit above and in the encoding you actually pass in: an MD5 hex digest is 32 characters and a SHA-256 one 64, both safe. A SHA-512 hex digest is 128 characters and is silently truncated, and base64 does not rescue it - 64 raw bytes encode to 88 characters, still past 72. Confirmed on PHP 8.5: two different base64(sha512(...)) values sharing their first 72 characters verify interchangeably under password_verify(). For a SHA-512 estate the options are to compress the stored digest through a shorter one before bcrypt - bcrypt(base64(hmac-sha384(sha512_digest, pepper)), salt, cost), whose 48-byte output is 64 base64 characters and fits, which is why OWASP's pre-hash form uses SHA-384 - or to layer that estate under Argon2id instead, which has no input-length limit and is the better destination anyway. The second detail is salt: if the legacy hash was salted, the layered form has to be computed over the same salted digest, which means keeping the old salt column until every row has reached the clean form.
- Batch-update every row to the new algorithm applied over the old digest, and record the layered format in the version column. No plaintext is needed and no user is disrupted
- Store which algorithm produced each password hash, in a hash_version column recording whether the row is legacy, layered, or clean
- On login, check the password against the layered form and against the clean new form
- When a user authenticates against the layered hash, immediately re-hash the plaintext directly with the new algorithm and update the row
- Track the percentage of users on the clean form
- After 90 days, force a password reset for inactive accounts still on the layered form
- Once 95%+ are on the clean form, stop accepting the layered one
Big-Bang Migration (NOT Recommended)
Force all users to reset passwords:
- Invalidate all existing password hashes
- Send password reset emails to entire user base
- Mark all accounts as requiring password reset
Every user is locked out until they reset via email: a large support burden, and a poor experience for the entire user base.
Rollback Procedures
If the migration causes problems:
- Revert the application to the previous version, which still supports the old algorithm
- Restore from backup if the stored hashes were already modified
- Temporarily re-enable dual-read if support for the old algorithm was removed too early
-
Communicate what is happening:
- Status page: "Investigating login issues"
- User email: "Temporary login problems resolved"
- Support team: "Use password reset for affected users"
Testing Recommendations
Pre-deployment testing:
- Test migration in staging with production data copy
- Verify old passwords still work (dual-read)
- Verify old passwords upgrade after successful login
- Verify new passwords work immediately
- Test failed login attempts do not break migration
- Verify migration tracking returns correct percentages
- Test password reset flow for un-migrated users
Post-deployment monitoring:
- Monitor login success and failure rates, which should stay flat, and alert on any rise in authentication errors
- Track the share of users on each hash version and the daily migration rate, and project a completion date from them
- Monitor support tickets for login issues
Additional Resources
- CWE-916: Use of Password Hash With Insufficient Computational Effort
- OWASP Password Storage Cheat Sheet
- Password Hashing Competition - Argon2 winner
- RFC 9106: Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work Applications - specifies the m, t and p parameters the Argon2id configurations above are expressed in
- NIST SP 800-63B rev 4: Digital Identity Guidelines
- OWASP Top 10 2025: A04 Cryptographic Failures