CWE-760: Use of a One-Way Hash with a Predictable Salt
Overview
A salt protects a password hash in full only when it is both unique per credential and unpredictable to an attacker. A salt that is shared across every user (a hardcoded constant), or derived from a value the attacker already knows or can enumerate (a user ID, a username, a timestamp), can be replicated or precomputed before the breach - which is exactly the work an unpredictable salt exists to make impossible. The two cases do not cost the same. A shared constant fails the same way as using no salt at all (CWE-759): identical passwords collide and one table cracks the whole database. A unique but derived salt still separates the rows and still costs the attacker a table per user - what it gives up is the timing, because those tables can be built in advance rather than after the dump.
Relationship to Other CWEs
- CWE-916 (Use of Password Hash With Insufficient Computational Effort) - the parent weakness: a password hash that is cheap to compute, whatever salt it is given.
- CWE-759 (Use of a One-Way Hash without a Salt) - the sibling variant, where no salt is used at all. 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.
- CWE-760 (this page) - the narrower case where a salt is present but is guessable, shared, or derived from a predictable value.
OWASP Classification
A04:2025 - Cryptographic Failures
Risk
High: A predictable salt lets an attacker precompute a rainbow table before the breach rather than after it, restoring the attack random salting was meant to prevent. A shared static salt exposes the entire user base to a single precomputed table; a salt derived from an enumerable value such as a sequential user ID exposes the whole ID range to one bulk precomputation.
Remediation Steps
Core Principle: A salt's security value comes entirely from being unpredictable and unique per credential - it must be generated from a cryptographically secure random source, never derived from a known value or shared across records.
Trace the Data Path
- Source: The value chosen as a salt when a password or secret is hashed.
- Sink: The hash function call that combines the salt with the password.
- Missing Control: The salt is a constant, or is computed from a value (user ID, username, timestamp, sequence number) that an attacker can know or enumerate, instead of being drawn from a cryptographically secure random generator.
Generate the Salt from a Cryptographically Secure Random Source (Primary Defense)
Every salt must come from a CSPRNG, generated fresh for each credential, with no relationship to any other value in the system. A purpose-built password hashing function (bcrypt, Argon2id, scrypt) does this automatically and stores the salt with the result, removing the chance of a developer substituting a derived or constant value by mistake.
Never Derive a Salt From an Application Value
Do not construct a salt from a user ID, username, email address, timestamp, or any other value that is stored, logged, guessable, or enumerable - even a value that looks unpredictable at a glance (an internal sequence number, a creation timestamp) is a small, precomputable search space compared to a true random salt.
Never Share a Salt Across Records
A single static salt configured once for the whole application does not do a salt's job. An attacker who obtains it - from source code, configuration, or a single leaked record - builds one table that attacks every record, and identical passwords still produce identical hashes whether or not anyone obtains it.
A shared secret is not worthless, but it is a pepper, not a salt, and the two are not alternatives. OWASP scopes a pepper to defence in depth in addition to per-password salting, kept out of the password database entirely - in a secrets vault or an HSM - where it stops an attacker who has the database and nothing else from cracking anything. OWASP's own summary is "alone, it provides no additional secure characteristics": the property is conditional on the secret staying out of reach, which is why a pepper supplements the hashing scheme rather than being part of it. It also cannot be rotated without every user's plaintext, since changing it invalidates every hash. Adding one does not resolve a CWE-760 finding; the per-credential random salt does.
Test the Fix
- Confirm the salt stored with each credential differs from every other record's salt, including records created close together in time or for sequential user IDs.
- Confirm no salt value in storage matches a user ID, username, or timestamp associated with that record.
- Re-scan with the security scanner to confirm the finding is resolved.
Common Vulnerable Patterns
One salt shared by every user
// VULNERABLE - pseudo-code, salt shared across every user
STATIC_SALT = "app-wide-salt-value"
password_hash = hash(password, STATIC_SALT)
// Attacker builds one rainbow table for STATIC_SALT, cracks every account
Why this is vulnerable: a salt's job is to make the attacker's work scale with the number of accounts rather than being amortised across them. A single shared value gives that up: one table computed once applies to the whole database, so cracking the ten-thousandth password costs a lookup rather than a hash.
It also reintroduces a property salting exists to remove. With the same salt everywhere, identical passwords produce identical hashes, so the stored data reveals which accounts share a password without anything being cracked - and the most-repeated hash in the table is the most common password, which is where an attacker starts. The value being long or random-looking changes neither effect, because both follow from it being the same for every row rather than from it being guessable. Keeping it out of the database makes it a pepper rather than a salt, and identical passwords still collide.
A salt derived from something predictable
// VULNERABLE - pseudo-code, salt derived from a predictable value
salt = user_id // sequential and enumerable
password_hash = hash(password, salt)
// Attacker precomputes tables for the whole ID range in advance
Why this is vulnerable: per-user salts defeat precomputation because the attacker cannot know the salt before the breach. Deriving the salt from the user record removes exactly that: the identifiers are sequential and their range is known, so tables for every salt the application will ever use can be built in advance, and the breach is followed by lookups rather than by work.
The same objection applies to any other field pressed into service here - a username, an email address, a registration timestamp - because all of them are either public or guessable. Several are also mutable, so changing one quietly breaks the hash that was computed under it.
Secure Patterns
// SECURE - pseudo-code, fresh CSPRNG salt with no relationship to any known value
salt = generate_random_bytes(32)
password_hash = password_hashing_function(password, salt)
store(user_id, salt, password_hash)
Why this works: A salt drawn from a cryptographically secure random generator, independent of any other value in the system, cannot be precomputed or replicated - there is no shortcut from "I know the user ID" or "I have the source code" to "I know the salt." Each credential stands alone: cracking one hash means attacking that hash's own salt, and gains the attacker nothing against any other record.
Common Pitfalls
- Lengthening or hiding the static salt: the constant is replaced with a longer random-looking one, or moved from source into a config file or environment variable. Every record still shares it, so one table still attacks all of them and identical passwords still collide; see the pepper distinction under
Never Share a Salt Across Records. - Salting correctly and leaving the algorithm alone: fresh random salts are added to an MD5 or SHA-256 scheme, which closes CWE-760 and leaves CWE-916 open. The salt stops precomputation; only a slow, purpose-built function makes each individual crack expensive.
- Reusing a record's salt when its password changes: the salt is generated once at registration and never regenerated, so a password change produces a new hash under a salt an attacker may already hold from an earlier dump. Generate a fresh salt on every hash.
Migration Considerations
What Breaks
Replacing a predictable salt with a random per-credential one invalidates every stored hash: the old digest cannot be reproduced under the new salt, so without a migration path every user is locked out on the next deploy.
Migration Approach
The plaintext is gone, so the procedure is the one CWE-916 sets out in full and CWE-759 summarises. Batch re-hash every stored digest under a password hashing function first - Argon2id, which has no input-length limit, rather than bcrypt, which reads at most 72 bytes and so cannot take a SHA-512 hex digest at all (CWE-759 has the digest lengths) - so no weakly salted digest is left sitting in the database waiting for its owner to log in. Then reach the clean form, the function applied to the password itself, as users authenticate, and force a reset for accounts that have not logged in within a defined window. OWASP's caveat applies: a layered hash is somewhat easier to crack than one taken directly from the password, so it is a transitional state and not the destination.
One constraint is specific to a predictably salted estate. The layered value is computed over the existing salted digest, so verification still runs the legacy hash first and the legacy salt remains a live input until every row reaches the clean form. Where that salt was derived rather than stored - a username, a user ID, a creation timestamp - there is no salt column to keep, and the derivation has to be materialised into one before the batch runs: the source field can change afterwards, and the digest cannot be reproduced without the exact value used at hashing time. Rows whose source field has already changed cannot be verified at all - those accounts were broken before this migration started, and they need a password reset rather than a migration. An unsalted estate (CWE-759) has neither problem, because its layered value is computed from the stored digest alone.
Rollback Procedures
The batch overwrites every stored digest, so a code rollback alone will not undo it: a build that only understands the legacy 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 the users who have already been upgraded.