"Salt and pepper" gets said as one phrase often enough that it's easy to assume they're basically the same idea, applied twice for good measure. They're not. They defend against two different attackers, one of them is a well-defined requirement with an RFC behind it and the other is an optional, genuinely tricky engineering tradeoff, and only one of them is supposed to end up in your database at all. This is the mechanics of both — what problem each one actually solves, and where each one lives.
At a glance
| Salt | Pepper | |
|---|---|---|
| Unique per | Password | Nothing — one value, shared system-wide |
| Secret? | No — safe to store in plaintext | Yes — the entire point is that it's not in the database |
| Stored | Embedded in the hash string itself | Separately — a secrets vault, HSM, or env var |
| Generated by | Your hashing library, automatically | You, once, manually or via your secrets infrastructure |
| Defends against | Precomputed lookup / rainbow table attacks | The password table being stolen without the rest of your infrastructure |
| Required? | Yes — every credible algorithm does this for you | No — optional defense in depth |
The problem salt actually solves
Hash the same input twice with the same algorithm and you get the same output, every time — that's not a bug, it's the definition of a hash function. Which means an attacker doesn't need your specific database to start attacking it: they can build a rainbow table — a giant precomputed set of password guesses mapped to their hashes — once, offline, with no time pressure, and reuse it against any database that hashed passwords the same unsalted way. This isn't theoretical. Windows' old LM hash had no salt at all, and free rainbow tables covering its entire practical keyspace circulated publicly for years — tools like Ophcrack could recover most LM-hashed passwords in seconds by looking them up rather than computing anything at attack time. The hash function itself wasn't the failure; the lack of a salt is what made precomputation possible in the first place.
A salt breaks this by making the input unique per password before it ever reaches the hash function. hash(password) becomes hash(password + salt), with a different random salt for every single stored password — including two users who happen to share the identical password. Now a precomputed table has to cover every possible salt value too, which multiplies the attacker's precomputation cost by the number of possible salts — astronomically large by design — and makes precomputing anything reusable pointless. The attacker is forced back to attacking each hash individually, at attack time, with whatever that specific stored salt turns out to be.
Note what this doesn't claim: a salt says nothing about how fast that per-hash attack runs once it starts. That's a separate property — the algorithm's speed, not its salting — and Why SHA-256 Should Not Be Used for Passwords covers exactly why a salted-but-fast hash is still a real problem. Password Hashing Explained covers the same distinction from the "what do I actually need" angle. This post stays on the mechanics of the salt and pepper themselves.
What makes a salt good — and the mistakes that undo it
- Random, from a cryptographically secure source. NIST SP 800-63B requires it be "generated by an approved random bit generator" — not
Math.random(), not a counter, not anything an attacker could predict or reproduce. - Unique per password, not per user or per system. Two accounts with the same password must still get different salts — that's what forces the attacker to redo the work for each one.
- Long enough that guessing it isn't the shortcut. NIST's floor is 32 bits, but the algorithms people actually use go well past that: RFC 9106 recommends a 16-byte (128-bit) salt for Argon2, and bcrypt has used a 16-byte salt since its original 1999 design — both far past the point where brute-forcing the salt itself is a realistic attack.
- Not a username, email, or sequential ID. These aren't random and aren't secret, but the more important flaw is reuse: the same user's salt never changes across a password reset, so an attacker who cracked an old hash gets a head start recognizing the same user's new one. A real salt is freshly generated every time a password is hashed, reset included.
Where the salt actually lives
A salt doesn't need to be secret, so mature libraries don't hide it — they embed it directly in the stored hash string, right next to the algorithm name and cost parameters. A bcrypt hash breaks down like this:
$2b$ 12$ N9qo8uLOickgx2ZMRZoMye IjZAgcfl7p92ldGxad68LJZdL17lhWy
| | | |
alg cost salt (16 bytes, hash (23 bytes,
| base64, 22 chars) base64, 31 chars)
cost factorVerifying a login attempt doesn't require looking anything up — the salt travels with the hash, so verify(password, storedHash) reads it straight out of the string, recomputes with it, and compares. Argon2's format does the same thing with its own layout — $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>. Password Hash Inspector decodes either format and shows you the salt it finds embedded, and Password Hash Generator generates a fresh random salt automatically every time you compute a new hash — this is the "most developers never call a salt-generation function directly" part in practice.
What a pepper adds on top
A salt protects against precomputation. It does nothing if an attacker gets your actual password table, salts and all — at that point they have everything they need to attack every hash directly. A pepper is aimed at exactly that scenario: one secret value, shared across every password in the system, that never enters the database at all. It lives somewhere an attacker who only compromised your database — a SQL injection, an exposed backup, a leaked read replica — wouldn't also get: a secrets vault, an HSM, or an environment variable your database credentials have no access to.
This isn't an OWASP-only idea dressed up with a food-themed name — NIST SP 800-63B describes essentially the same construction under a different name, an optional "secret salt" with at least 112 bits of security strength, "generated by an approved random bit generator and stored separately" from the regular salt and hash, ideally in a dedicated hardware security module. Same idea, two standards bodies, two names.
How a pepper is actually implemented
Two real patterns, both described by OWASP's cheat sheet:
- Pre-hashing: concatenate the pepper with the password before it reaches bcrypt/Argon2 —
argon2(password + pepper, salt). Simple, but changes the input your hashing library sees, so it has to happen at hashing time, every time. - Post-hashing, via HMAC: hash the password normally first, then apply an HMAC — HMAC-SHA256 or HMAC-SHA512 — keyed with the pepper, over the resulting hash:
HMAC(key=pepper, message=argon2(password, salt)). This is a keyed hash in exactly the sense HMAC Generator computes — the pepper plays the same role there as a shared secret key does in any HMAC, proving whoever produced the value held the key. Because it's applied after the fact, it's often easier to bolt onto a system that already has bcrypt or Argon2 wired up, without touching the hashing call itself.
The catch: a pepper is hard to rotate
A salt never needs rotating — it's already unique per password, so there's nothing to gain by changing it. A pepper is the opposite: it's one value protecting every password at once, which makes it a single point of failure if it leaks, and genuinely awkward to rotate if you ever need to. Rotating means every stored hash was computed with the old pepper, so a naive swap breaks every login simultaneously.
The workable pattern mirrors the same rehash-on-login approach used for upgrading cost factors, covered in Password Hashing Best Practices: keep the old pepper available for verification, and on each successful login, recompute the hash with the new pepper before overwriting the stored value. Accounts that don't log in during the transition window stay on the old pepper until they do — which is exactly why treating the pepper itself as a secret worth real secrets-management infrastructure matters more than it might seem at first: losing track of an old pepper mid-rotation locks out every account that hasn't logged in since.
What a pepper doesn't do
- It doesn't replace a slow hashing algorithm. Concatenating a hardcoded pepper string onto a password before running it through a single fast hash — the "just add a secret string" instinct — barely slows an attacker down and is a different, much weaker thing than what's described here; see the "Hashing it twice doesn't fix it either" section of Why SHA-256 Should Not Be Used for Passwords for exactly why that shortcut fails. A pepper is meant to sit on top of a real bcrypt or Argon2id hash, not substitute for one.
- It doesn't help if the attacker gets both your database and whatever holds the pepper — a fully compromised server, not just a leaked database export. It's a defense against one specific, realistic failure mode, not every failure mode.
- It isn't free. A mismanaged pepper — one checked into the same repository as the code that uses it, or stored in the same place as the database credentials — protects against nothing while adding real operational complexity. Skip it if you don't already have somewhere genuinely separate to keep it.
Try it yourself
Password Hash Generator generates a fresh random salt with every bcrypt, scrypt, Argon2, or PBKDF2 hash it computes, so you can see one embedded in real output. Password Hash Inspector does the reverse — paste an existing hash and it decodes the salt, algorithm, and cost parameters back out. And HMAC Generator computes the exact keyed-hash construction a post-hashing pepper implementation relies on. All three run entirely in your browser.