Salting means adding a unique, random value (the salt) to each password before it is hashed, so two users with the same password end up with different stored hashes. It is the reason a leaked password table can’t be cracked with one precomputed lookup, and it is a basic requirement of any sound password storage scheme.
Below are three short Python scripts: plain SHA-256 (which leaks), a hand-made salt with PBKDF2, and Argon2id, where the library handles the salt for you. If you want the Node.js version of “use a library, don’t roll your own”, see Password Authentication in Node.js: A Step-by-Step Guide.
One honest caveat: these are small local teaching scripts, not an authentication system. I ran them with Python 3.12.12 and argon2-cffi 25.1.0, and the screenshots below are real terminal output from those runs. There is no database, no rate limiting, no account lockout and no transport security. The hand-rolled PBKDF2 version exists to show what a salt does, and in a real application you should use a maintained password hashing library as in script 3. The guidance here follows the OWASP and NIST documents linked below (checked November 2026).
Short answer: what is salting
Salting is generating a fresh random value for every password, mixing it into the hash, and storing it next to the result. According to the OWASP Password Storage Cheat Sheet, a salt is a unique, randomly generated string added to each password as part of the hashing process, and a unique salt must be added to every password.
The salt is not a secret and it does not make a weak password strong. It makes every stored hash unique, which breaks precomputed tables and stops an attacker from spotting users who share a password. You will also see this called password salting, or “salting and hashing”.
Why plain hashes leak
A hash function is deterministic: the same input always gives the same output. That is useful for checksums and a problem for passwords. Here is script 1, which hashes the same password for two different users with plain SHA-256.
import hashlib
password = "correct horse".encode("utf-8")
users = ["alice", "bob"]for user in users: digest = hashlib.sha256(password).hexdigest() print(f"{user}: {digest}")Run it:
python plain_sha256.py
Two things go wrong when a database stores hashes like this:
- Shared passwords are visible. Anyone who reads the table can see that alice and bob chose the same password, and one cracked hash exposes every user with that hash.
- Precomputed tables work. An attacker can hash millions of common passwords once, store the results (a lookup table, or a compressed form such as a rainbow table), and then reverse any matching hash by looking it up. Because the hash has no per-user input, the same table works against every site that uses the same plain hash.
SHA-256 is also designed to be fast, which helps an attacker guess quickly. We’ll come back to that.
Adding a salt by hand
The fix for both problems is a random salt per user. Script 2 uses only the standard library: secrets.token_bytes(16) for the salt, hashlib.pbkdf2_hmac for a deliberately slow hash, and hmac.compare_digest to compare results safely.
For PBKDF2-HMAC-SHA256, OWASP’s minimum is 600,000 iterations, which is the number used here.
import hashlibimport hmacimport secrets
ITERATIONS = 600_000
def hash_password(password: str) -> tuple[bytes, bytes]: salt = secrets.token_bytes(16) # new random salt for every password digest = hashlib.pbkdf2_hmac( "sha256", password.encode("utf-8"), salt, ITERATIONS ) return salt, digest # store both
def verify_password(password: str, salt: bytes, expected: bytes) -> bool: candidate = hashlib.pbkdf2_hmac( "sha256", password.encode("utf-8"), salt, ITERATIONS ) return hmac.compare_digest(candidate, expected)
if __name__ == "__main__": password = "correct horse"
alice_salt, alice_hash = hash_password(password) bob_salt, bob_hash = hash_password(password)
print("alice salt:", alice_salt.hex()) print("alice hash:", alice_hash.hex()) print("bob salt: ", bob_salt.hex()) print("bob hash: ", bob_hash.hex())
print("alice, right password:", verify_password("correct horse", alice_salt, alice_hash)) print("alice, wrong password:", verify_password("wrong horse", alice_salt, alice_hash))python salted_pbkdf2.py
The password is identical for both users, but the salts differ, so the hashes differ. That removes the shared-hash giveaway, and a precomputed table would have to be rebuilt for every single salt, which defeats the point of precomputing.
Three details matter here:
- Store the salt with the hash. Verification needs the same salt, so keep it in the same row (or encode it in the same string). It is not secret.
- Use
secrets, notrandom. Thesecretsmodule is built for security-sensitive randomness. - Compare with
hmac.compare_digest. It avoids leaking information through comparison timing.
How long should the salt be?
NIST SP 800-63B rev. 4, section 3.1.1.2, says passwords must be salted and hashed with a suitable password hashing scheme, and that the salt must be at least 32 bits and chosen to minimize collisions between salts. 16 random bytes (128 bits), as used above, is comfortably past that minimum and is a common choice.
What libraries do for you: Argon2id
Writing the glue yourself is where mistakes creep in, such as a reused salt, a wrong parameter or a sloppy comparison. OWASP notes that modern algorithms (Argon2id, bcrypt, PBKDF2) need a salt, but that most widely used libraries generate and manage it for you.
Script 3 uses the argon2-cffi package, which implements Argon2id.
pip install argon2-cffifrom argon2 import PasswordHasherfrom argon2.exceptions import VerifyMismatchError
ph = PasswordHasher()password = "correct horse"
alice = ph.hash(password)bob = ph.hash(password)
print("alice:", alice)print("bob: ", bob)
try: print("right password:", ph.verify(alice, "correct horse"))except VerifyMismatchError: print("right password: False")
try: ph.verify(alice, "wrong horse") print("wrong password: True")except VerifyMismatchError: print("wrong password: False")
# Simulate an old hash made with weaker settings (below OWASP's minimum on purpose)old_ph = PasswordHasher(time_cost=1, memory_cost=8192, parallelism=1)old_hash = old_ph.hash(password)
print("current hash needs rehash:", ph.check_needs_rehash(alice))print("old hash needs rehash: ", ph.check_needs_rehash(old_hash))python argon2_demo.py
Each output is a single encoded string that looks like this:
$argon2id$v=19$m=...,t=...,p=...$<salt>$<hash>The string carries everything verification needs: the algorithm, version, memory cost (m), time cost (t), parallelism (p), the salt and the hash. You never generate, pass around or store a salt yourself, and that is the point of using the library. Two hashes of the same password differ because each call picks a fresh salt.
check_needs_rehash compares the parameters inside a stored hash with your current settings. The usual pattern is to verify the password at login, and if the hash needs a rehash, store a new one made with the current parameters.
For reference, OWASP’s minimum configurations (checked November 2026) are:
| Algorithm | Minimum settings |
|---|---|
| Argon2id | m = 19 MiB (19456 KiB), t = 2, p = 1 (or m = 46 MiB, t = 1, p = 1) |
| scrypt | N = 2^17, r = 8, p = 1 |
| bcrypt | work factor of at least 10; legacy use; 72-byte input limit |
| PBKDF2-HMAC-SHA256 | 600,000 iterations (220,000 for HMAC-SHA512) |
Does salting stop brute force?
No. A salt stops identical hashes and precomputed tables, but an attacker who has your table can still guess passwords one hash at a time. For a weak password like “password123”, that guess lands quickly regardless of salt.
This is why the hash must also be slow and, for Argon2id and scrypt, memory-hungry. Salting and a slow hash work together: the salt forces the attacker to attack each hash individually, and the cost factor makes every guess expensive.
Salt vs pepper
A pepper is a secret value shared across all stored passwords and kept outside the database, for example in a secrets vault or an HSM. OWASP describes it as used in addition to salting, not instead of it. NIST SP 800-63B rev. 4 also recommends an additional keyed hash using a secret key stored separately from the hashes, which is the same idea.
| Salt | Pepper | Hash | |
|---|---|---|---|
| Secret? | No | Yes | Treat as sensitive |
| Unique per user? | Yes | No, shared across passwords | Yes (because of the salt) |
| Where stored | Next to the hash | Separate from the database (vault, HSM) | In the database |
| Main job | Make identical passwords hash differently, defeat precomputed tables | Make a stolen database alone insufficient to crack hashes | Verify the password without storing it |
Common mistakes
- One global salt for everyone. This brings back the shared-hash problem across users. Every password needs its own salt.
- Treating the salt as a secret. It can sit in the same row as the hash. If you want a secret, that is a pepper.
- Salting a fast hash and calling it done. Salted SHA-256 is better than plain SHA-256, but it is still built for speed. Use Argon2id, bcrypt, scrypt or PBKDF2 with proper cost settings.
- Short or predictable salts. Use a cryptographically secure random generator and at least the NIST minimum of 32 bits. 16 bytes is a sensible default.
- Rolling your own scheme. Use a maintained library and let it handle the salt, encoding and comparison.
- Never upgrading old hashes. Check parameters at login and rehash when they fall below your current settings.
Password storage is only one part of a login system. For the wider picture, see Unlocking Web Security: Master JWT Authentication and STRIDE Threat Modeling Explained.
Is a salt secret?
No. A salt is stored alongside the hash, and in Argon2id and bcrypt it is embedded in the same string. Its job is uniqueness, not secrecy. If you need a secret value, use a pepper.
How long should a salt be?
NIST SP 800-63B rev. 4 requires at least 32 bits. In practice, 16 random bytes (128 bits) from a secure generator such as Python’s secrets module is a common and comfortable choice. Libraries pick a suitable length for you.
What is the difference between salt and pepper?
A salt is unique per password, not secret and stored with the hash. A pepper is shared across passwords, secret and stored separately from the database. They solve different problems and are meant to be used together, not as alternatives.
Should I salt bcrypt myself?
No. bcrypt generates a random salt and stores it inside the output string, and most bcrypt libraries do this automatically. Remember bcrypt’s 72-byte input limit, and per OWASP treat it as a legacy choice where Argon2id is available.