← Back to postsCoding Notes
EnglishPublished Jun 20, 2026Updated Jun 20, 20268 min read

Hashing, Salt, Pepper & Encryption: A Practical Reference

Tips

A guide to how passwords and OTPs are protected, why "you can't reverse a hash" is true but incomplete, and how salt and pepper defend against different breaches. The throughline: security comes from making guessing infeasible, not from irreversibility alone.


1. Hashing vs. Encryption — Not the Same Thing

The most important distinction to get right first.

EncryptionHashing
Reversible?Yes — by designNo — by design
Needs a key?YesNo
How you get the original backDecrypt with the keyYou don't — there is no inverse
Output sizeVaries with inputFixed (e.g. SHA-256 → always 256 bits)
Typical useProtecting data you need to read later (messages, files, tokens in transit)Verifying a value without storing it (passwords, OTPs)

Encryption is a two-way door: ciphertext + key → plaintext. If you have the key, you can always recover the original.

Hashing is a one-way door: there is no key and no inverse function. You cannot take a hash and compute the input back. This is exactly why passwords are hashed, not encrypted — even if the database leaks, there's nothing to "decrypt."

js
// Encryption — reversible with a key
const ciphertext = encrypt("secret", key);
const original   = decrypt(ciphertext, key);   // → "secret"

// Hashing — no way back
const hash = sha256("secret");                 // → "2bb80d537b1d..."
// there is NO sha256_decrypt(). It does not exist.

2. "Can I Reverse a Hash?" — No, But You Can Guess It

This is the subtlety that ties everything together.

You cannot reverse a hash — there's no inverse operation, full stop. But attackers don't reverse hashes. They guess the input, hash each guess, and check for a match:

code
hash("0000") → compare to target
hash("0001") → compare
... until one matches

Finding a match recovers the input — not by reversing the hash, but by discovering which input produces it. So whether a hash is "safe" depends entirely on how many guesses are needed, which depends on the input, not the hash function.

InputGuessable?Why
4-digit OTP (10,000 options)❌ TriviallyHash all 10,000 in milliseconds → match found
password123❌ YesIn every wordlist; among the first guesses
Long random token / strong password✅ Practically noAstronomically many possibilities

Two reasons "reverse" is the wrong mental model:

  • Hashes are many-to-one. Infinite inputs map to each fixed-size output, so there's no unique input to reverse to. Cracking finds an input that matches.
  • Precomputation is the shortcut. An attacker can hash common inputs once and store the results — a rainbow table — making small or common inputs fall instantly. Salt and pepper exist to defeat this.

Bottom line: "can't reverse" does not mean "safe." A hash protects its input only when that input is drawn from a space too large to guess.


3. Salt — Public, Per-Record, Stored With the Hash

A salt is a random value, unique per user, stored right alongside the hash. It is not secret — the server must read it back to verify a login, so it leaks with the database.

Its job: defeat precomputation and cross-user attacks. Without a salt, identical passwords produce identical hashes, so an attacker precomputes one rainbow table and cracks everyone at once. A unique salt makes every hash different even for the same password:

  • Rainbow tables become useless (they'd need rebuilding per salt).
  • The attacker must crack each account separately, one at a time.
js
// bcrypt generates a random salt and embeds it in the output
const hash = await bcrypt.hash(password, 12);

What it does NOT do: stop someone cracking a single hash. The salt is in the stolen DB, so the attacker has it — they just can't take shortcuts across users.


4. Pepper — Secret, Global, Stored Outside the Database

A pepper is a secret value, typically the same for all records, kept out of the database — in an environment variable, app config, or a secrets manager.

js
// pepper is mixed in BEFORE hashing and is NOT stored in the DB
const hash = sha256(code + process.env.PEPPER);

Its job: make a database-only breach survivable. Since the pepper isn't in the dump, an attacker with only the database is missing an ingredient in every guess — so they can't reverse the hashes, even for a tiny keyspace like a 4-digit OTP:

code
attacker tries:  sha256("0000"), sha256("0001"), ...
stored value is: sha256("0000" + secretPepper), ...
                              └──────────────┘ attacker doesn't have this
→ none of the guesses match. Precomputation is dead.

What it does NOT do:

  • Protect against an attacker who also has the pepper (e.g. full server compromise).
  • Stop online guessing through the live API — the running app applies the pepper for every request, so an attacker submitting codes to your endpoint is unaffected by it. (That's what rate limiting and lockout are for.)

5. Salt vs. Pepper — Side by Side

The core difference is one location decision, and everything else follows from it.

SaltPepper
Secret?No — publicYes — secret
Unique per user?Yes, one per recordNo, one global value (typically)
Stored where?In the DB, with the hashOutside the DB (env / secrets manager)
Leaks if the DB leaks?YesNo — that's the whole point
Defends againstRainbow tables; cracking many users at onceA DB-only breach (attacker lacks the secret)
RotationAutomatic — new random salt per hashAwkward — old hashes used the old pepper; needs versioning

The mental model — they defend different breaches:

  • Salt assumes the attacker will get the hashes and asks: can they take shortcuts across users? → No.
  • Pepper assumes the attacker got only the database and asks: do they have everything needed to crack at all? → No, they're missing the secret.

This is why you use both as layers, not one or the other. A salt wouldn't help against a DB leak (it leaks too); a pepper stored in the DB would just be a worse salt. Swapping their defining properties breaks them.


6. Why bcrypt — Slowness Is the Real Defense

Once salting forces per-account guessing, what actually stops the guessing is that bcrypt is deliberately slow.

A fast hash (MD5, plain SHA-256) lets an attacker try billions of guesses per second. bcrypt has a tunable cost factor making each hash intentionally expensive:

js
bcrypt.hash(password, 12)   // 2^12 rounds — each hash takes ~250ms

That slowness is negligible for one real login but brutal at attack scale — dropping an attacker from billions of guesses/sec to a few thousand. Raise the cost factor as hardware improves.

Two things ultimately decide if a password survives a breach:

  1. bcrypt's cost factor — buys time per guess.
  2. Password strength — decides whether that time is "minutes" or "millennia." A weak password falls regardless; it's simply an early guess.

7. Anatomy of a bcrypt Hash

A bcrypt hash is a single 60-character string that packs the algorithm, cost, salt, and hash together:

code
$2a$08$npPvaEGtsq0MvtG3Lk266OqII/W/7vv3WfLyGlIIBy520.6RGRFIi
└┬┘└┬┘└──────────┬─────────┘└──────────────┬──────────────┘
 │  │            │                          │
 │  │            │                          └─ hash (31 chars)
 │  │            └─ salt (22 chars)
 │  └─ cost factor: 08  (2^8 = 256 rounds)
 └─ algorithm version: 2a

Read positionally after the $ delimiters:

  • Version + cost: 2a$08 — bcrypt version 2a, cost factor 08.
  • Salt: the next exactly 22 characters — bcrypt's base-64 encoding of the 16-byte random salt.
  • Hash: the remaining 31 characters — the derived digest.

How to recognize bcrypt: always 60 chars, starts with $2a$, $2b$, or $2y$. After the third $, the first 22 chars = salt, next 31 = hash.

⚠️ Don't eyeball where the salt ends — salt and hash run together with no delimiter. The split is positional (22 / 31), not visual.

On verification: the server extracts the embedded salt, re-hashes the entered password with that same salt and cost, and compares. This is why the salt must be stored in the open — and why a stolen bcrypt hash hands the attacker the salt too. If the app also uses a pepper, it was mixed into the password before bcrypt and leaves no trace in the stored string.

If a string isn't 60 chars and doesn't start with $2…$…$, it isn't bcrypt — it's likely a random token (session ID, API key, reset token) or a hash from another algorithm, and you shouldn't expect a salt inside it.


8. Putting It All Together

The defenses layer because each one covers a gap the others leave open:

LayerStopsLimitation
Hashing (one-way)Storing plaintext; trivial reading on leakUseless alone for small/weak inputs
Salt (in DB)Rainbow tables; bulk cross-user crackingLeaks with the DB; doesn't slow single-hash guessing
Pepper (outside DB)A DB-only breachUseless if server is fully compromised; doesn't stop online guessing
bcrypt slowness (cost factor)Fast offline guessingDoesn't help if the password is weak
Strong / high-entropy inputPushes guess count beyond feasibilityOut of your control for user-chosen passwords
Rate limit / lockout / short TTLOnline guessing via the live APIDoesn't help against an offline stolen DB

The honest summary:

  • You cannot reverse a cryptographic hash — there is no inverse operation.
  • But "can't reverse" ≠ "safe," because an attacker can guess.
  • Salt and pepper are not interchangeable: salt (public, in DB) stops cross-user shortcuts; pepper (secret, outside DB) makes a database leak survivable.
  • For small-keyspace secrets like OTPs, online defenses (lockout, rate limit, short TTL) are essential — pepper only protects the offline / stolen-DB case.
  • Encryption is the separate tool for data you actually need to read back later — it's reversible with a key, unlike everything else here.