1Salting and Advanced Password Protection Techniques
▶
When a system stores user passwords, the most dangerous mistake it can make is storing them in a form that allows an attacker who gains database access to recover those passwords quickly. Hashing passwords is the foundational countermeasure, but hashing alone is not enough. A technique called salting — combined with purpose-built, computationally expensive hashing algorithms — forms the backbone of modern password protection. Understanding why salting matters, how it works mechanically, and how it integrates with advanced algorithms like bcrypt, scrypt, and Argon2 is essential for anyone building or auditing authentication systems.
What Is Salting and Why It Matters
A salt is a randomly generated value that is appended or prepended to a password before it is hashed. The resulting hash is therefore not just a hash of the password itself, but a hash of the password combined with a unique random string. This seemingly small change has profound security consequences.
Consider what happens without salting. If two users both choose the password hunter2, a standard hash function such as SHA-256 will produce the exact same digest for both:
SHA-256("hunter2") = f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7
SHA-256("hunter2") = f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7
An attacker looking at the database immediately knows both accounts share the same password. Worse, cracking one cracks both. With salting, each user receives a different random salt, so even identical passwords produce entirely different stored values:
SHA-256("hunter2" + "a3f9c21b...") = 8d07e329... (User A)
SHA-256("hunter2" + "7b14e803...") = c4f19a02... (User B)
A critical point that trips up many developers: the salt does not need to be secret. It is typically stored in plaintext right next to the password hash in the database. Its purpose is not confidentiality — it is uniqueness. The salt's job is to ensure that each hash in the database is derived from a unique input, so that no two accounts share the same hash even when they share the same password.
Defending Against Rainbow Table Attacks
A rainbow table is a precomputed data structure that maps hash values back to the passwords that produced them. An attacker who builds a rainbow table for SHA-256 can, in theory, look up any hash in the table and instantly find the corresponding password — no brute-force computation required at attack time. All the computation happens once, during table construction, and the resulting table can be reused against any database that uses the same unsalted hashing scheme.
Salting destroys the utility of rainbow tables entirely. Here is why: to build a rainbow table that works against salted hashes, an attacker would need to precompute hashes not just for every possible password, but for every possible password combined with every possible salt value. With a 16-byte (128-bit) random salt, there are 2128 possible salt values. Even if an attacker focused on only the most common passwords, they would need to compute a separate hash for each password paired with each possible salt. The storage and computation requirements become astronomically infeasible.
This is why modern authentication systems always store a per-user salt alongside the password hash. During login, the system retrieves the stored salt for the user, combines it with the password the user just entered, hashes the combination, and compares the result to the stored hash. The salt is not a secret — the attacker can see it in the database — but that does not help them build a precomputed table, because the salt is unique to that user and was chosen randomly.
| Attack Type | Without Salting | With Salting |
|---|---|---|
| Rainbow Table Lookup | Instant — precomputed tables work directly | Tables are useless; must recompute per account |
| Batch Dictionary Attack (all accounts) | One hash per guess covers all accounts simultaneously | Each account requires independent computation |
| Single-Account Brute Force | Limited only by hash function speed | Limited only by hash function speed (salting alone does not help here) |
Defending Against Dictionary and Brute-Force Attacks
A dictionary attack involves trying a large list of likely passwords — common words, known breached passwords, variations — against stored hashes. Without salting, an attacker can hash each dictionary word once and check it against every account in the database simultaneously. With a million accounts, a single hash computation tests all million accounts at once. This bulk efficiency is what salting eliminates: with per-user salts, the attacker must compute a separate hash for each password guess per account, multiplying the workload by the number of accounts.
However, there is an important limitation to understand: salting alone does not slow down an attack targeting a single account. If an attacker focuses on cracking one specific user's password, they retrieve that user's salt, then start guessing passwords and computing hashes just as fast as the underlying hash function allows. A modern GPU can compute billions of SHA-256 hashes per second. Against a single account, salting provides no speed penalty — the attacker simply incorporates the known salt into every guess.
This is where key stretching becomes essential. Salting and key stretching are complementary, not interchangeable: salting defeats bulk attacks and precomputation; key stretching slows down attacks on individual accounts.
Generating and Storing Salts
Generating salts correctly matters enormously. A salt that is too short, predictable, or reused across accounts provides much weaker protection than intended.
- Length: Salts should be at least 16 bytes (128 bits) long. This ensures that even with a massive user base of hundreds of millions of accounts, the probability of any two users having the same salt remains negligibly small, preserving the uniqueness property that makes salting effective.
- Randomness: Salts must be generated using a cryptographically secure pseudo-random number generator (CSPRNG), not a standard library random function. In Python, for example,
os.urandom(16)orsecrets.token_bytes(16)are appropriate;random.random()is not. - Uniqueness: Every user account must have its own independently generated salt. Reusing the same salt across multiple accounts recreates the problem salting is meant to solve — accounts with the same password will again produce the same hash.
- Storage: The salt is stored alongside the hash, typically in the same database row. There is no security benefit to storing it elsewhere, and doing so adds operational complexity without gain.
The login flow with salting works as follows:
# Registration
salt = generate_random_salt() # e.g., 16 random bytes
hash = slow_hash_function(password + salt)
store(user_id, salt, hash)
# Login
stored_salt, stored_hash = retrieve(user_id)
candidate_hash = slow_hash_function(entered_password + stored_salt)
if candidate_hash == stored_hash:
# Authentication successful
Key Stretching
Key stretching deliberately makes the hash computation slow. The core idea is simple: instead of computing a hash once, the algorithm applies the hash function (or a related computation) thousands or millions of times in sequence. Because each iteration depends on the output of the previous one, the process cannot be parallelized to complete faster — it must run through all iterations serially.
PBKDF2 (Password-Based Key Derivation Function 2) is one of the earliest and most widely used key-stretching constructions. It takes a password, a salt, an iteration count, and a pseudorandom function (typically HMAC-SHA-256), and runs the PRF iteratively:
PBKDF2(password, salt, iterations=600000, hash=SHA-256)
OWASP's current recommendation for PBKDF2-HMAC-SHA-256 is 600,000 iterations. At this setting, even a high-end server takes a noticeable fraction of a second to compute a single hash — imperceptible to a legitimate user logging in, but devastating for an attacker trying to test millions of password guesses.
The cost parameter (iteration count) is tunable and should be reviewed and increased over time as hardware improves. A system deployed today with 600,000 iterations should be redesigned to use more iterations five years from now, when the same computation will be proportionally cheaper. Modern formats like bcrypt, scrypt, and Argon2 store the cost parameter inside the hash string itself, making it straightforward to recognize and upgrade outdated hashes.
The asymmetry between legitimate use and attack is the key insight: a 300-millisecond login delay is completely acceptable to a real user and represents a negligible operational cost. But if an attacker has a list of 100 million password guesses and must spend 300 milliseconds on each one, cracking a single account would take nearly 10 years on a single machine. Even with a large cluster, the cost becomes prohibitive.
Modern Password Hashing Algorithms: bcrypt and scrypt
bcrypt was designed in 1999 specifically for password hashing and remains widely used today. It incorporates both salting and key stretching in a single integrated algorithm. Its defining feature is a cost factor (also called the work factor), an integer that controls the number of iterations as a power of two:
bcrypt(password, cost=12)
# Performs 2^12 = 4,096 iterations of the core function
When bcrypt generates a hash, it automatically generates a random 128-bit salt, embeds it in the output string along with the cost factor, and returns a self-contained string like:
$2b$12$EXRkfkdmXn2gzds2SSitu.MW9.TNunO3a7bTe7tJeoGTsJHZOAg2K
^^ ^^ ^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^
alg cost salt (22 chars) hash (31 chars)
This self-contained format means the application only needs to store one string per user, and the verification function automatically extracts the salt and cost factor to reproduce the hash. The cost factor can be increased over time: when a user next logs in, the system can rehash their password with the new cost factor and store the updated string.
bcrypt's limitation is that it is purely CPU-bound. Attackers using GPUs or FPGAs can still gain significant parallelism advantages over a CPU-based server, though the per-iteration cost reduces this advantage considerably.
scrypt, introduced in 2009, addressed this limitation by adding a memory-hardness requirement. scrypt requires not just CPU time but a configurable amount of RAM to compute. Its parameters include:
- N — the CPU/memory cost parameter (must be a power of 2)
- r — the block size, which scales memory and CPU usage together
- p — parallelization factor
scrypt(password, salt, N=2^17, r=8, p=1)
# Requires ~128 MB of RAM and significant CPU time
Memory hardness is important because specialized hardware like ASICs and FPGAs can be built with many parallel processing units but have limited RAM per unit. An algorithm that requires 128 MB of RAM per computation prevents an attacker from running thousands of parallel computations on a single chip — they would need 128 MB of fast memory for each parallel thread. This dramatically narrows the hardware advantage attackers enjoy over defenders.
Both bcrypt and scrypt handle salt generation and embedding automatically, reducing the risk of developer errors such as forgetting to generate a salt, reusing salts, or storing the salt and hash in incompatible formats.
Argon2: The Modern Standard
Argon2 won the Password Hashing Competition in 2015 and is now the algorithm recommended by OWASP, the IETF, and most current security guidelines for new systems. It refines and unifies the lessons of PBKDF2, bcrypt, and scrypt into a single, well-analyzed design with three independently tunable parameters:
| Parameter | Controls | Typical Starting Value |
|---|---|---|
Time cost (t) |
Number of iterations over the memory block | 1–3 iterations |
Memory cost (m) |
Amount of RAM required, in kibibytes | 64 MB (65536 KiB) or more |
Parallelism (p) |
Number of parallel threads used during computation | 1–4 threads |
This fine-grained control allows security engineers to tune Argon2 to precisely the resource budget available on their servers — maximizing the cost imposed on attackers without making the login experience unacceptably slow.
Argon2 comes in three variants:
- Argon2d — maximizes resistance to GPU cracking by making memory access data-dependent, but is potentially vulnerable to side-channel timing attacks. Suitable for applications without side-channel risk (e.g., cryptocurrency).
- Argon2i — uses data-independent memory access patterns, eliminating side-channel timing attacks, at some cost to GPU resistance. Suitable for password hashing in environments with side-channel risk.
- Argon2id — a hybrid: uses data-independent access for the first half of the computation and data-dependent access for the second half. This provides strong GPU resistance while maintaining side-channel resistance, making it the recommended variant for general password hashing according to OWASP and RFC 9106.
Like bcrypt and scrypt, Argon2 automatically generates and embeds a random salt in its output string, so the full output is self-contained and portable:
$argon2id$v=19$m=65536,t=2,p=1$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
The output encodes the algorithm variant, version, parameters, salt, and hash — everything needed for verification in a single string.
Argon2's memory hardness makes it particularly resistant to attacks using ASICs (Application-Specific Integrated Circuits) and FPGAs (Field-Programmable Gate Arrays), which attackers have used to crack bcrypt hashes at speeds far exceeding what general-purpose CPUs can achieve. Because Argon2 requires a large, configurable block of RAM, building dedicated cracking hardware requires incorporating proportional RAM — eliminating most of the cost and size advantage such hardware otherwise offers.
The combined picture of modern password protection therefore looks like this: a randomly generated, per-user salt of at least 16 bytes eliminates precomputed attacks and bulk dictionary attacks; key stretching via a purpose-built algorithm imposes significant per-guess cost on attackers targeting individual accounts; and memory hardness neutralizes the advantage of specialized cracking hardware. Algorithms like Argon2id implement all three properties in a single, well-tested, standards-backed package, which is why they represent the current best practice for any system that needs to store user passwords securely.