Salting, Peppering, and Why You Never Store Plain Hashes of Passwords

Every few months, another data breach surfaces. The headlines vary, but the post-mortems rarely do: millions of accounts exposed, and somewhere buried in the technical teardown, the phrase "passwords were stored as MD5 hashes." Security practitioners groan. The attackers rejoice. And a few thousand users quietly discover their "uncrackable" password hunter2 was cracked in milliseconds.

Here is the thing that continues to surprise people unfamiliar with cryptography: hashing a password is not the same as protecting it. A hash is a one-way mathematical function. You feed it input, you get a fixed-length digest. SHA-256 of "password123" will always be ef92b778bafe771207981.... That determinism is exactly what makes hashes useful for integrity verification — and exactly what makes them dangerous for storing secrets.

The Rainbow Table Problem

Before we talk solutions, you need to understand why raw hashes fail so badly. When an attacker steals a database of SHA-256 or MD5 hashes, they do not need to reverse the hash function (which is computationally infeasible). They simply precompute hashes for every common word, phrase, and variation they can think of, build a lookup table, and compare. If your hash is in there, your password is recovered in microseconds.

These precomputed tables — called rainbow tables — can cover billions of passwords and take up only a few hundred gigabytes on disk. Services like CrackStation publish free tables that cover most passwords under ten characters. Upload a leaked MD5 hash, get the plaintext back. It feels like magic. It is just a lookup.

The obvious naive fix is to add some secret data to the password before hashing. This is where the terms "salt" and "pepper" enter the picture, and they are not interchangeable.

What a Salt Actually Does

A salt is a random value, unique per user, stored alongside the hash in your database. When a user registers with the password "correct horse battery staple," you generate a random 16-byte salt — say 7f3a91b2... — concatenate it with the password, hash the combination, and store both the salt and the resulting hash.

When the user logs in again, you fetch their salt from the database, apply the same operation, and compare. If the hashes match, the password is correct.

The immediate benefit: rainbow tables become useless. Every user has a different salt, so identical passwords produce different hashes. An attacker who steals your database now has to crack each account individually, hashing candidate passwords combined with that specific user's salt. The precomputed table they spent weeks building is worthless.

The subtler benefit: salts prevent cross-database correlation. If two users on your platform both use "qwerty123," their hashes will differ. If the same password appears in a breach from a different service, there is no way to match it against yours by hash comparison alone.

A critical implementation note: salts must be random, not predictable. Using the username as a salt, for example, is dangerously wrong. An attacker who knows your salting scheme can precompute a rainbow table that accounts for it. The salt needs to come from a cryptographically secure random number generator — in Python that means secrets.token_bytes(), not random.randbytes(). In Node.js, crypto.randomBytes(). The distinction matters.

Pepper: The Database-Independent Secret

A pepper is conceptually similar to a salt but serves a different threat model. Where a salt is stored in the database (visible to anyone who exfiltrates it), a pepper is a secret value stored outside the database — typically in an environment variable, a secrets manager, or a hardware security module.

The workflow: you append (or HMAC-combine) the pepper with the password before salting and hashing. An attacker who steals only your database gets the hashes and the salts, but not the pepper. Without the pepper, brute-force cracking becomes orders of magnitude harder because they cannot even verify whether a candidate password is correct.

Peppers shine in scenarios where database exfiltration is the primary attack vector — SQL injection, a rogue DBA, a misconfigured backup. If the attacker also compromises your application servers and environment, the pepper is exposed too. Defense in depth, not a silver bullet.

One practical complication with peppers: rotation. When you rotate a pepper (which you should do periodically, or after a suspected exposure), you cannot simply re-hash everything because you do not have access to plaintext passwords. The standard approach is to re-hash on next successful login, gradually migrating users to the new pepper over time.

Why SHA-256 Is Wrong for Passwords Regardless

Even with a salt and pepper, SHA-256 is the wrong tool for password hashing. SHA-256 was designed to be fast. Extremely fast. A modern GPU can compute billions of SHA-256 hashes per second. That speed is a feature when you are hashing large files for integrity checks. It is a catastrophic liability when you are defending passwords against brute force.

Password hashing requires the opposite property: slowness, by design. The functions built for this purpose — bcrypt, scrypt, Argon2 — include a configurable work factor that makes each hash computation take a meaningful amount of time. Ten milliseconds on your server is barely noticeable to a legitimate user logging in. For an attacker trying to test a billion candidate passwords, those ten milliseconds per attempt translate to roughly 115 days of single-machine work. Raise the work factor, and you can push that further still.

bcrypt: The Workhorse

bcrypt was designed in 1999 by Niels Provos and David Mazières specifically for password hashing. It incorporates a salt automatically (you do not manage it separately), and exposes a cost factor — typically a number between 10 and 14 — that doubles the computation time for each increment.

At cost factor 12, bcrypt takes roughly 250ms per hash on modern hardware. That is comfortable for user logins and punishing for attackers. The cost factor is stored in the hash output itself, so if you increase it later, existing hashes remain valid (they just use the old cost) while new registrations and password changes use the higher factor.

The bcrypt output looks like this: $2b$12$EXRkfkdmXn2gzds2SSitu.MW9.TNq7cP.... The $2b$ identifies the algorithm variant, 12 is the cost factor, the next 22 characters are the salt, and the rest is the hash. Everything you need to verify is packed into one string, which is why storing it is straightforward.

One genuine limitation: bcrypt truncates input at 72 bytes. A passphrase longer than 72 characters offers no additional protection. In practice this rarely matters — few users have passwords that long — but it is worth knowing.

Argon2: The Modern Standard

Argon2 won the Password Hashing Competition in 2015 and represents the current state of the art. It has three variants: Argon2d (GPU-resistant), Argon2i (side-channel resistant), and Argon2id (the recommended hybrid). For most applications, Argon2id is the answer.

What Argon2 adds over bcrypt is memory hardness. The algorithm requires a configurable amount of RAM during computation. This is significant because modern GPU and ASIC attacks are dramatically cheaper when the function fits entirely in cache. By requiring, say, 64MB of memory per hash, Argon2 forces attackers to provision expensive high-bandwidth memory at scale, not just raw compute.

Configuration parameters include time cost (iterations), memory cost (in kilobytes), and parallelism (thread count). A reasonable starting point for 2025 hardware: time=3, memory=65536 (64MB), parallelism=4. Benchmark on your own hardware and tune until hash computation takes between 100–500ms — fast enough for user experience, expensive enough to deter attacks.

The Encoding Layer: Base64 and Why It Is Not Hashing

One persistent confusion worth addressing directly: Base64 encoding is not hashing, and it is not encryption. Base64 converts binary data to printable ASCII text. It is entirely reversible — atob("cGFzc3dvcmQxMjM=") gives you back "password123" in about a microsecond. If you have ever seen a database storing Base64-encoded passwords and someone calls it "encrypted," that is a serious vulnerability masquerading as security theater.

Base64 appears legitimately in the password hashing pipeline when libraries need to store binary hash output as printable text, but it is a transport encoding, not a security mechanism. The security comes from the hash function. Base64 is just legibility.

The Practical Checklist

If you are building or auditing a system that stores passwords, here is what correct looks like:

  • Use Argon2id or bcrypt. Not SHA-256, not MD5, not SHA-512. Not even PBKDF2 unless you have specific compliance requirements that mandate it (and if you do, use it with HMAC-SHA-256 and at least 600,000 iterations, per OWASP 2023 guidance).
  • Let the library handle salts. bcrypt, libsodium, and passlib generate cryptographically random salts internally. Do not roll your own.
  • Store only the hash output. Never log plaintext passwords, never store them in session data, never transmit them after hashing is complete.
  • Implement a pepper if your threat model includes database exfiltration without full server compromise. Store it outside the database — environment variable at minimum, secrets manager if available.
  • Tune your work factor annually. Hardware gets cheaper. What was expensive in 2020 may be cheap today. OWASP maintains current recommendations.
  • Enforce a maximum password length if you use bcrypt (72 bytes). Alternatively, pre-hash with SHA-256 and base64-encode the result before passing to bcrypt — this effectively removes the length limit while preserving bcrypt's properties.

One Final Observation

The difference between storing sha256("hunter2") and storing a properly tuned Argon2id hash is not a matter of degree. It is the difference between a lock on a screen door and a bank vault. The mathematical one-way property is identical; everything else — the salt, the memory hardness, the configurable time cost — exists because password hashing is an adversarial problem, not an integrity-verification problem. The attacker's goal is not to invert the hash. Their goal is to try enough candidates until one matches. Everything in modern password hashing is designed to make that process brutally expensive.

MD5 was never designed for passwords. Neither was SHA-256. Using them for that purpose is not cutting corners — it is using the wrong tool entirely, with entirely predictable consequences when the inevitable breach arrives.