What is Salting in Security? (Password Protection Explained)
- A salt is a unique random string per user, combined with their password before hashing. Stored in plaintext β its purpose is uniqueness, not secrecy.
- Without salting, identical passwords produce identical hashes β one cracked hash cracks all users with that password simultaneously via rainbow tables.
- Never use raw SHA-256/MD5 for passwords. Use bcrypt (battle-tested), Argon2id (modern, memory-hard), or PBKDF2 (FIPS-approved). They handle salting automatically.
In 2012, LinkedIn's password database was breached. 117 million accounts. The passwords were stored as unsalted SHA-1 hashes. Within days, security researchers had cracked over 90% of them using pre-computed rainbow tables β databases that map known hashes back to their original passwords. The passwords had been hashed, technically, but without salting the protection was nearly worthless.
The same year, Adobe lost 153 million records β also using unsalted MD5 with an added twist: they used the same encryption key ('hint') for all passwords, meaning if you cracked one user with a given hint, you got every user with that hint simultaneously.
Salting is not a complicated concept. It is three lines of code. But failing to do it correctly β or at all β is one of the most common and consequential security mistakes in web development. This guide explains exactly what salting is, why it works, and how to implement it correctly in 2026.
Hashing Without Salt β The Rainbow Table Problem
A hash function converts a password to a fixed-length digest: SHA-256('password123') always produces the same 64-character hex string. This is deterministic β and that determinism is the vulnerability.
An attacker who steals your password database does not need to 'decrypt' anything. They can:
Pre-compute a rainbow table: Hash every word in a dictionary, every common password, every string up to 8 characters. Store the (hash β plaintext) mapping. This costs computation time once but can be reused against any unsalted database forever.
Lookup: For each stolen hash, look it up in the table. If found, they have the plaintext password in O(1) time.
Scale: SHA-256 is designed to be fast β roughly 500 million hashes per second on a GPU. Cracking a 10-million-entry database of unsalted SHA-256 hashes of common passwords takes minutes, not years.
import hashlib # VULNERABLE β never do this in production def bad_hash_password(password: str) -> str: return hashlib.sha256(password.encode()).hexdigest() # Every user with 'password123' gets the SAME hash print(bad_hash_password('password123')) print(bad_hash_password('password123')) # identical # A rainbow table lookup would crack both instantly # Real rainbow tables cover all passwords up to 8 chars: # That's 95^8 β 6.6 quadrillion possibilities β pre-computed once, reused forever
ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f
How Salting Works
A salt is a cryptographically random string generated uniquely per user, stored alongside their hash. The password + salt combination is hashed together.
The salt does not need to be secret β it is stored in plaintext in your database next to the hash. Its purpose is not confidentiality but uniqueness: even two users with identical passwords will have different salts and therefore different hashes. A rainbow table built against unsalted hashes is completely useless against salted ones β the attacker would need to rebuild the table for every possible salt value, which is computationally infeasible.
import hashlib import os # CORRECT manual salting (for illustration β use bcrypt in production) def hash_password(password: str) -> tuple[str, str]: # Generate a unique 32-byte random salt per user salt = os.urandom(32).hex() # Hash password+salt together digest = hashlib.sha256((password + salt).encode()).hexdigest() return digest, salt # store BOTH in database def verify_password(password: str, stored_hash: str, stored_salt: str) -> bool: digest = hashlib.sha256((password + stored_salt).encode()).hexdigest() return digest == stored_hash # Same password β completely different hashes hash1, salt1 = hash_password('password123') hash2, salt2 = hash_password('password123') print(f'Hash 1: {hash1[:32]}...') print(f'Hash 2: {hash2[:32]}...') print(f'Same? {hash1 == hash2}') # False β salts are different # Verification works correctly print(verify_password('password123', hash1, salt1)) # True print(verify_password('wrongpassword', hash1, salt1)) # False
Hash 2: 7k2m9p4q1r5s...
Same? False
True
False
The Right Way: bcrypt, Argon2, PBKDF2
The salting example above still uses raw SHA-256 β which is designed for speed, not password security. On a GPU, an attacker can try billions of SHA-256 hashes per second. The solution is not just salting but using a dedicated password hashing function that:
- Handles salting automatically β generates and stores the salt internally
- Is deliberately slow β configurable work factor that can be tuned as hardware gets faster
- Is memory-hard (Argon2) β requires large amounts of RAM per attempt, making GPU/ASIC attacks expensive
bcrypt: The battle-tested default. Cost factor doubles computation time per increment. Cost 12 takes ~250ms β comfortable for users, brutal for attackers trying 10 billion guesses.
Argon2id: 2015 Password Hashing Competition winner. Memory-hard: an attacker needs GBs of RAM per guess, not just compute. The modern recommendation for new systems.
PBKDF2-SHA256: SHA-256 iterated 100,000+ times with salt. FIPS-approved. Django's default.
# pip install bcrypt argon2-cffi import bcrypt from argon2 import PasswordHasher # ββ bcrypt ββββββββββββββββββββββββββββββββββββββββββββββββββββββ def bcrypt_hash(password: str) -> bytes: # bcrypt generates and embeds the salt automatically return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)) def bcrypt_verify(password: str, hashed: bytes) -> bool: return bcrypt.checkpw(password.encode(), hashed) hashed = bcrypt_hash('mysecretpassword') print(f'bcrypt hash: {hashed}') print(f'Verify correct: {bcrypt_verify("mysecretpassword", hashed)}') print(f'Verify incorrect: {bcrypt_verify("wrongpassword", hashed)}') # ββ Argon2id (recommended for new systems) βββββββββββββββββββββββ ph = PasswordHasher(time_cost=2, memory_cost=65536, parallelism=2) def argon2_hash(password: str) -> str: return ph.hash(password) # salt embedded in the hash string def argon2_verify(password: str, stored_hash: str) -> bool: try: return ph.verify(stored_hash, password) except Exception: return False arg_hash = argon2_hash('mysecretpassword') print(f'\nArgon2 hash: {arg_hash}') print(f'Verify: {argon2_verify("mysecretpassword", arg_hash)}')
Verify correct: True
Verify incorrect: False
Argon2 hash: $argon2id$v=19$m=65536,t=2,p=2$...
Verify: True
Hashing vs Salting vs Encryption β Comparison Table
These three terms are frequently confused. They serve different purposes and have different threat models:
| | Hashing | Salted Hashing | Encryption | |---|---|---|---| | Reversible? | No | No | Yes (with key) | | Purpose | Integrity verification | Password storage | Confidential data storage | | Key required? | No | No | Yes | | Rainbow tables? | Vulnerable | Immune | Not applicable | | Same input = same output? | Yes | No (different salt each time) | Yes (same key) | | Example use | File checksums, git commits | User passwords | Credit card numbers, PII | | Examples | SHA-256, MD5 | bcrypt, Argon2, PBKDF2 | AES-GCM, RSA |
Rule of thumb: Passwords β salted hashing (bcrypt/Argon2). Sensitive data you need to retrieve β encryption (AES-GCM). File integrity β plain hashing (SHA-256).
Never encrypt passwords. If your database and encryption key are both compromised, all passwords are recoverable. Properly salted bcrypt hashes remain computationally infeasible to crack even after a database breach.
What Good Password Storage Looks Like in Production
A production-grade password system has several layers beyond just salting:
Pepper: An additional secret value (unlike salts, peppers are NOT stored in the database β stored in application config or HSM). Even if an attacker gets your database, they cannot crack passwords without the pepper.
Rehashing on login: When a user logs in successfully, check if their stored hash uses an outdated algorithm or cost factor. If so, rehash with the current settings and update the database. This lets you upgrade your password security without requiring password resets.
Timing-safe comparison: Use hmac.compare_digest() not == when comparing hashes. Regular string equality short-circuits on first mismatch β creating a timing side channel. Timing-safe comparison always takes the same time.
import bcrypt import hmac import os from functools import wraps PEPPER = os.environ.get('PASSWORD_PEPPER', '').encode() def hash_password(password: str) -> str: """Production-grade password hashing with pepper + bcrypt.""" # Combine password with pepper before hashing peppered = password.encode() + PEPPER return bcrypt.hashpw(peppered, bcrypt.gensalt(rounds=12)).decode() def verify_password(password: str, stored_hash: str) -> bool: """Timing-safe password verification.""" peppered = password.encode() + PEPPER candidate = bcrypt.hashpw(peppered, stored_hash.encode()) # Timing-safe comparison β prevents timing oracle attacks return hmac.compare_digest(candidate, stored_hash.encode()) def needs_rehash(stored_hash: str, target_rounds: int = 12) -> bool: """Check if stored hash should be upgraded.""" return bcrypt.checkpw(b'', stored_hash.encode()) == False or \ bcrypt.gensalt(rounds=target_rounds)[:7] != stored_hash.encode()[:7] # Usage hashed = hash_password('MySecurePassword!') print(f'Stored: {hashed}') print(f'Valid: {verify_password("MySecurePassword!", hashed)}') print(f'Wrong: {verify_password("WrongPassword", hashed)}')
Valid: True
Wrong: False
π― Key Takeaways
- A salt is a unique random string per user, combined with their password before hashing. Stored in plaintext β its purpose is uniqueness, not secrecy.
- Without salting, identical passwords produce identical hashes β one cracked hash cracks all users with that password simultaneously via rainbow tables.
- Never use raw SHA-256/MD5 for passwords. Use bcrypt (battle-tested), Argon2id (modern, memory-hard), or PBKDF2 (FIPS-approved). They handle salting automatically.
- LinkedIn (2012, 117M accounts) and Adobe (2012, 153M accounts) were both breached partly due to unsalted or improperly salted password storage.
- The salt does not need to be secret. A pepper (application-level secret) adds a second layer that prevents cracking even after a database breach.
Interview Questions on This Topic
- QWhy doesn't hashing alone protect passwords, even with a strong algorithm like SHA-256?
- QWhat is a rainbow table attack and how does salting defeat it?
- QWhy should you use bcrypt or Argon2 instead of SHA-256 for passwords even with salting?
- QWhat is a pepper and how does it differ from a salt?
- QWhy should you use hmac.compare_digest() instead of == when comparing password hashes?
Frequently Asked Questions
Should I store the salt in the database?
Yes β the salt must be stored because you need it to verify the password on login. Salts are not secret; they are unique. An attacker who gets your database gets the salts too, but that only means they must crack each hash individually rather than using pre-computed tables. The computational cost of bcrypt/Argon2 makes this infeasible at scale.
How long should a salt be?
At least 16 bytes (128 bits) of cryptographically random data. bcrypt uses 16 bytes internally. Argon2 uses 16 bytes by default. The length matters to prevent collisions β with a 128-bit salt, the probability of two users getting the same salt is negligible even with billions of users.
Is bcrypt deprecated? Should I switch to Argon2?
bcrypt is not deprecated β it remains widely deployed and secure. For new systems, Argon2id is the recommendation (PHC winner, memory-hard, side-channel resistant). For existing systems using bcrypt with a reasonable cost factor (12+), there is no urgent need to migrate. The most important thing is using any modern dedicated password hashing function rather than raw SHA/MD5.
What is the difference between Argon2i, Argon2d, and Argon2id?
Argon2d: resistant to GPU attacks (uses data-dependent memory access). Argon2i: resistant to side-channel attacks (uses data-independent memory access). Argon2id: hybrid β first half uses data-independent access, second half uses data-dependent. Argon2id is the recommended default for password hashing, providing protection against both attack vectors.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.