CWE-759: Use of a One-Way Hash without a Salt
Overview
Hashing a password or other secret without a salt means every user with the same password gets the same stored hash. An attacker who steals the hash database can crack it with a precomputed rainbow table or a dictionary attack, and a match against one hash reveals every other account sharing that password.
Relationship to Other CWEs
- CWE-916 (Use of Password Hash With Insufficient Computational Effort) - the parent: password hashing that costs an attacker too little work.
- CWE-759 (this page) - no salt is used at all.
- CWE-760 (Use of a One-Way Hash with a Predictable Salt) - the sibling case where a salt is present but is guessable, shared, or derived from a predictable value. MITRE lists no direct relationship between the two, but they divide the same ground: both defeat salting's purpose, and both have the same fix - a fresh, unpredictable, per-credential salt.
OWASP Classification
A04:2025 - Cryptographic Failures
Risk
Critical: A stolen hash database exposes the whole user base at once rather than one account at a time. Cracking a password once compromises every account that shares it, and precomputed rainbow tables recover common passwords with no cracking work at all.
Remediation Steps
Core Principle: Never hash a password or comparable secret without a fresh, cryptographically random salt included in the input.
Trace the Data Path
- Source: A password or secret submitted at registration or password change.
- Sink: The hash function call that stores the credential.
- Missing Control: No salt is mixed into the input before hashing, so identical inputs always produce identical outputs.
Use a Password Hashing Function That Manages Salting Automatically (Primary Defense)
Use a purpose-built password hashing algorithm - Argon2id, scrypt, or bcrypt - that generates a unique random salt for every hash and stores it alongside the result. There is no separate salt-handling step to forget.
Generate a Fresh, Random Salt Per Credential If Implementing Manually
If a purpose-built function cannot be used, generate a new salt from a cryptographically secure random source for every credential - never a shared constant, and never derived from a value that repeats or can be guessed - and store the salt alongside the hash so the hash can be reproduced at verification time.
Feed that salt into a key-derivation function with a tunable cost - PBKDF2-HMAC-SHA256 at 600,000 iterations, the option FIPS-140 validation permits - and not into a bare sha256(salt + password). Salting and slowness are separate properties. A per-credential salt on a fast hash satisfies both of the salt checks below while leaving the estate in CWE-916: each hash now has to be cracked on its own, but each one is still cracked at billions of guesses per second.
Test the Fix
- Hash the same password twice (as two different users) and confirm the stored hashes differ.
- Confirm the salt used for each hash is present in storage and is not identical across records.
- Time a single hash on production-class hardware: it should land in milliseconds, not microseconds. The gap is four orders of magnitude, so the check does not need a precise target - measured on CPython 3.13, PBKDF2-HMAC-SHA256 at 600,000 iterations costs 298 ms and a bare
sha256()0.017 ms. A microsecond result means the salt was added and the algorithm was not; see the first pitfall below. - Re-scan with the security scanner to confirm the finding is resolved.
Common Vulnerable Patterns
// VULNERABLE - pseudo-code, no salt at all
password_hash = hash(password)
store(user_id, password_hash)
// User1 "password123" -> same hash as User2 "password123"
// Attacker cracks one hash, compromises every account sharing that password
Why this is vulnerable: without a salt the hash is a pure function of the password, so identical passwords produce identical stored values. That gives an attacker two things before any cracking starts. Precomputation applies: a table built once against the algorithm works against this database and every other one using it, so the cost of the first crack is also the cost of the millionth. And the stored data shows which accounts share a password without anything being broken - the most frequently repeated hash is the most common password in the system, which is where an attacker begins.
The defect is the absence of uniqueness, not of secrecy, which is what makes the near-miss fixes fail. A salt does not need to be hidden and is stored alongside the hash by design; a value that is secret but shared across every row still lets one table crack everything, and so does one derived from the username or the user id, because both are known in advance (CWE-760). What is required is a fresh value from a cryptographic random source for every hash - which a purpose-built password-hashing function generates, stores in its own output format, and reads back at verification.
Secure Patterns
// SECURE - pseudo-code, unique random salt per credential
salt = generate_random_bytes(32) // fresh CSPRNG salt, every time
password_hash = password_hashing_function(password, salt)
store(user_id, salt, password_hash)
// Identical passwords now produce different stored hashes
Why this works: a unique random salt means two users with the same password get different stored hashes, so an attacker who cracks one hash learns nothing about any other account. Because the salt differs per record, no precomputed table covers more than one hash, and cracking the database means cracking every row separately.
Common Pitfalls
- Salting a fast hash and stopping there: a unique random salt is added to an MD5 or SHA-256 scheme, so identical passwords no longer collide and precomputed tables no longer apply - and every individual hash is still cracked at GPU speed. This closes CWE-759 and leaves CWE-916 open; the salt and the work factor are separate controls and the finding usually needs both.
- Hiding the salt instead of randomising it: the salt is moved into a config file or a separate table on the theory that it must be kept secret, while the same value is still used for every record. A salt's property is uniqueness, not secrecy - it is stored beside the hash by design - and a hidden shared value is CWE-760, not a fix for this one.
- Reusing a record's salt when its password changes: the salt is generated once at registration and kept for the life of the account, so a password change produces a new hash under the old salt. Anyone holding a dump from before the change can carry their per-salt work straight over to the new hash. Generate a fresh salt on every hash, which a purpose-built function does without being asked.
- Migrating only on login: salted hashing is switched on for new and changed passwords while existing rows keep their unsalted digests until their owners happen to sign in. Dormant accounts stay unsalted indefinitely, and a database dump does not skip them. The batch pass below closes that window without needing anyone's plaintext.
Migration Considerations
Adding salted hashing where none existed invalidates every stored hash.
What Breaks
An unsalted hash cannot be verified against the new format directly, so every existing user's stored hash stops matching what a salted verification would produce. Without a migration path, all users are locked out on the next deploy.
Migration Approach
Upgrading each record at its owner's next login - dual-read alone - leaves every un-migrated unsalted hash sitting in the database for the whole migration window, and a database dump does not wait for users to log in. Close that exposure in one pass first: batch re-hash every stored digest under the password hashing function, so the stored value becomes argon2id(stored_digest), with the algorithm generating a fresh per-row salt. No plaintext is needed and no user is disrupted.
Then reach the clean form - the function applied to the password itself - as users authenticate: verify the candidate password against the layered form (apply the legacy hash first, then verify the result), and on success re-hash the plaintext directly and overwrite the row. Track which form each record is in, and prompt users who have not logged in within a defined window (90 days, for example) for a password reset rather than waiting indefinitely. CWE-916 has the full procedure, including the version column, the cutover, and OWASP's caveat that a layered hash is somewhat easier to crack than one taken directly from the password - so it is a transitional state, not the destination.
Two things are specific to an unsalted estate. The first works in your favour: there is no salt column to carry forward, so the layered value is computed from the stored digest alone and the legacy column can be dropped as soon as the batch completes. (A salted legacy estate - CWE-760 - has to keep its old salt until every row reaches the clean form, because the layered form is only reproducible over the same salted digest.) The second is a constraint on choosing bcrypt for the outer function: bcrypt reads at most the first 72 bytes of its input, measured 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 and base64 of the raw 64-byte digest is still 88, so a SHA-512 estate cannot be layered under bcrypt at all - everything past byte 72 is ignored, and digests sharing a 72-character prefix verify interchangeably. Use Argon2id as the outer function, which has no input-length limit and is the better destination anyway.
Rollback Procedures
The batch overwrites every stored digest, so a code rollback alone will not undo it: a build that only understands the raw unsalted format authenticates nobody once the layering has run. Deploy the verification code that handles the layered and clean forms first, confirm it is live on every instance, then run the batch - and take a database backup immediately beforehand, which is the only route back. Keep both verification paths in place until every record has reached the clean form, so a later rollback of application code does not lock out users who have already been upgraded.