LOADING 0%
// nav_menu.exe
Home Resume Blog Contact Order
English فارسی
~/blog / security / password-hashing-salt-pepper

WHAT ARE HASH, SALT AND PEPPER? WHY PASSWORDS SHOULD NOT BE SIMPLY ENCRYPTED

Suppose a user signs up on our site with the password BlueTrain!2026. The simplest approach is to store that exact text in the database. A slightly more professional one is to encrypt it with a key and store the ciphertext. In both cases, the server can one day recover the original password.

What Are Hash, Salt and Pepper? Why Passwords Should Not Be Simply Encrypted

# First Ask the Right Question: What Should Be Stored?

The more important question is: why should the server be able to recover the user's password at all? To log in, we only need to determine whether the entered password is correct. We never need to know its original value, display it, or send it to anyone. That small difference changes the design of the entire password storage system.
At registration, the application derives a "checkable value" (a Verifier) from the password and stores only that:
Registration
Password
Password Hashing Function
Verifier
Database
At login, the entered password passes through the same process. If the result matches the stored value, access is granted:
Login
Entered password
Recompute verifier
Safe comparison
Match? Access granted
There is no decrypt() anywhere in this flow. The server should never have a "show the user's current password" option. If a user forgets the password, the correct path is to create a new one via Password Reset — not to email them the old one.
Key point: Authentication doesn't need the password itself; it needs evidence that lets us check a new guess against it.

# Encryption, Hashing and Password Hashing Are Not the Same Thing

These three concepts are often all called "encryption" in everyday conversation, but they behave differently:
ENC
Encryption — reversible, keyed
Hiding data that must be read again later
HASH
General hash — one-way, keyless
Data fingerprinting, integrity checks, identification
KDF
Password Hashing / KDF — deliberately slow
Making password guessing expensive; usually salted
Encryption is bidirectional:
encryption.txt
Plaintext + Key → Ciphertext
Ciphertext + Key → Plaintext
That property is useful for card numbers, confidential documents, or data that must be read again. But if passwords are encrypted, a key exists that turns all of them back into plaintext. If an attacker reaches the config file, backup, or the secret for that key, the entire password set is recoverable at once.
A hash has no defined reverse path:
hash.txt
Password → Hash
Hash     → Password  
Yet the phrase "hashes are irreversible" can create a misconception. The attacker doesn't need to reverse the hash; they can hash candidate passwords one by one and compare outputs:
guessing.log
Guess: 123456       → Hash → no match
Guess: password     → Hash → no match
Guess: BlueTrain!2026 → Hash → match ✓
So hashes usually aren't decrypted; they are found by making many guesses.

# Why SHA-256(password) Is Still Not Secure

Suppose we wrote this for registration:
insecure_hash.py
# demo only — shows the problem
 
import hashlib
 
 
def hash_password(password: str) -> str:
    return hashlib.sha256(password.encode("utf-8")).hexdigest()
 
 
print(hash_password("123456"))
The output looks random and long, but it has two fundamental problems. First, the same input always produces the same output: if a hundred users pick 123456, all hundred hashes are identical, and an attacker can tell those accounts share a password before cracking anything.
The second problem matters more: SHA-256 was designed to be fast. Speed is a virtue for file integrity and digital signatures, but for password storage it lets an attacker try enormous numbers of guesses cheaply. A tiny educational attack:
crack_demo.py
# attack against our own demo hash only
 
import hashlib
 
 
target = hashlib.sha256(b"sunshine").hexdigest()
 
guesses = [
    "123456",
    "password",
    "qwerty",
    "sunshine",
    "letmein",
]
 
for guess in guesses:
    candidate = hashlib.sha256(guess.encode("utf-8")).hexdigest()
 
    if candidate == target:
        print("Password found:", guess)
        break
$ python crack_demo.py
Password found: sunshine
We didn't reverse the hash; we made five guesses and ran the same function on them. A real attacker uses leaked password lists, dictionaries, common patterns, and parallel hardware. That's why a fast hash — even one cryptographically unbroken like SHA-256 — is not, by itself, a proper password hash.

# What Is Salt? One Password, Two Different Outputs

Salt is a random, unique value generated for every password hash, and it enters the computation before the password hashing function runs:
The role of Salt
Password
+ random Salt
Password Hashing Function
Verifier
Suppose Ali and Reza happen to pick the same password:
salt_demo.txt
Ali
Password = sunshine
Salt     = 8f2a...
Result   = a34c...
 
Reza
Password = sunshine
Salt     = c91d...
Result   = 77b0...
Both passwords are sunshine, but because the salts differ, the outputs differ too. The attacker can no longer hash sunshine once and compare it against every user; a separate computation is needed for each record's salt. Precomputed tables like Rainbow Tables also lose their value.
Salt is not meant to be secret. It is usually stored right next to the hash in the same database column, as part of a string like this:
encoded_verifier.txt
$argon2id$v=19$m=19456,t=2,p=1$RANDOM_SALT$PASSWORD_HASH
That string carries the algorithm name, version, memory cost, iteration count, parallelism, salt, and the final result. Salt sitting in the database is not a security flaw; the design assumes it is public.
Simplification warning: Adding a salt to SHA-256 fixes duplicate outputs but does not turn SHA-256 into a proper password hash; the algorithm is still fast. Salt blocks precomputation and one-shot attacks across many accounts, but it does not stop per-password guessing of weak passwords.

# What Is Pepper? A Salt That Must Not Live in the Database

Pepper is also an extra input to the password computation, but its role and storage differ from salt:
Salt

Unique per password, public, stored next to the hash in the database.

Public + unique
Pepper

Shared across records, secret, never stored beside the hash.

Secret + outside the database
If an attacker steals only a database dump or backup, they get the salts — salts are public by design. But if the pepper is kept separately in a secret manager, it isn't in the database file, and the attacker is still missing an essential ingredient for checking guesses:
storage_layout.txt
Database
  ├─ Algorithm
  ├─ Parameters
  ├─ Salt
  └─ Password Verifier
 
Secret Manager / HSM
  └─ Pepper
Pepper should not simply be glued to the password as password + pepper. A more standard approach is HMAC, where the pepper acts as the key of a keyed hash:
Pepper + Argon2id
HMAC-SHA256 (key = Pepper)
Argon2id + Salt
Verifier
In this design, HMAC produces a fixed input that is unusable without the pepper. Argon2id then builds the final verifier with a random salt and an appropriate computational cost.
But pepper is not a magic shield. If an attacker compromises the whole application server, the running program may have pepper access. Pepper helps most in the scenario where the database alone is exposed. And if the pepper leaks, rotating it is not simple: building new verifiers requires the users' original passwords, so a pepper incident usually forces a password reset.
Operational note: Pepper is a Defense in Depth layer — not a replacement for salt, the right algorithm, rate limiting, or MFA. A system with pepper and fast SHA-256 is still badly designed.

# Work Factor: Why a Password Algorithm Should Be Deliberately Slow

For a real user, computing one hash at login happens once. If it takes a fraction of a second, the experience barely changes. But an offline attacker pays that same cost for every single guess:
Real user

1 login × hash cost

Acceptable
Attacker

Millions of guesses × same cost

Expensive and slow
Modern algorithms don't just burn CPU; they can also consume a tunable amount of memory. This property is called Memory Hardness. Massive parallel guessing on GPUs or custom hardware becomes far more expensive when each guess needs significant memory.
At the time of writing, the recommended default choice for new projects is Argon2id. Its three important parameters:
m
Memory Cost
How much memory to consume
t
Time Cost
How many passes to run
p
Parallelism
How many parallel lanes to use
The OWASP guide for Argon2id recommends a minimum configuration of m=19456 KiB, t=2, and p=1. This is a starting point, not a sacred number for every server: settings must be benchmarked on real hardware and raised as computing power grows.
The goal isn't to needlessly slow down login. An oversized work factor can raise CPU/RAM usage and even create a denial-of-service path. The right point is tuned against system size, login volume, server capacity, and rate limiting.

# Let's Build a Real System with Argon2id

For the practical sample we use the argon2-cffi library. It generates a fresh random salt for every hash and packs everything needed for verification into the standard encoded string:
terminal
$ python -m pip install argon2-cffi
First generate a random 32-byte pepper and place it in an environment variable for the demo:
generate_pepper.sh
$ python -c "import base64,secrets; print(base64.b64encode(secrets.token_bytes(32)).decode())"
 
# Linux / macOS
$ export PASSWORD_PEPPER="BASE64_VALUE_HERE"
 
# Windows PowerShell
$env:PASSWORD_PEPPER="BASE64_VALUE_HERE"
In production: an environment variable is only a simple example. The pepper should live in a secret-management service or HSM and be fetched at runtime with proper access control — not in source code, Git, Docker images, or the user database itself.
The file below is a complete implementation covering hashing, login verification, and upgrading old parameters:
password_store.py
from __future__ import annotations
 
import base64
import binascii
import hashlib
import hmac
import os
 
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError
from argon2.low_level import Type
 
 
# Matches OWASP's current minimum;
# benchmark on real hardware in production.
PASSWORD_HASHER = PasswordHasher(
    time_cost=2,
    memory_cost=19 * 1024,  # KiB = 19 MiB
    parallelism=1,
    hash_len=32,
    salt_len=16,
    type=Type.ID,
)
 
 
def load_pepper() -> bytes:
    encoded = os.environ.get("PASSWORD_PEPPER")
 
    if encoded is None:
        raise RuntimeError("PASSWORD_PEPPER is not configured")
 
    try:
        pepper = base64.b64decode(encoded, validate=True)
    except (binascii.Error, ValueError) as error:
        raise RuntimeError("PASSWORD_PEPPER must be valid Base64") from error
 
    if len(pepper) < 32:
        raise RuntimeError("PASSWORD_PEPPER must contain at least 32 random bytes")
 
    return pepper
 
 
PEPPER = load_pepper()
 
 
def pepper_password(password: str) -> str:
    """Create a keyed, fixed-length input for Argon2id."""
 
    digest = hmac.new(
        key=PEPPER,
        msg=password.encode("utf-8"),
        digestmod=hashlib.sha256,
    ).digest()
 
    return base64.b64encode(digest).decode("ascii")
 
 
def hash_password(password: str) -> str:
    """Return the encoded Argon2id verifier stored in the database."""
 
    return PASSWORD_HASHER.hash(pepper_password(password))
 
 
def verify_password(stored_hash: str, password: str) -> tuple[bool, str | None]:
    """
    Return (valid, upgraded_hash).
 
    upgraded_hash is not None when the password is correct but
    the stored Argon2 parameters are older than the current policy.
    """
 
    candidate = pepper_password(password)
 
    try:
        PASSWORD_HASHER.verify(stored_hash, candidate)
    except (VerificationError, InvalidHashError):
        return False, None
 
    if PASSWORD_HASHER.check_needs_rehash(stored_hash):
        return True, PASSWORD_HASHER.hash(candidate)
 
    return True, None
 
 
if __name__ == "__main__":
    password = "BlueTrain!2026"
    stored_hash = hash_password(password)
 
    print("Stored:", stored_hash)
    print("Correct:", verify_password(stored_hash, password)[0])
    print("Wrong:", verify_password(stored_hash, "wrong-password")[0])
Sample output (salt and hash change on every run):
output
$ python password_store.py
 
Stored: $argon2id$v=19$m=19456,t=2,p=1$...$...
Correct: True
Wrong: False
Several important things happen inside this code:
01
HMAC-SHA256 with pepper
Turns the password into a keyed input for Argon2id
02
PasswordHasher.hash()
Generates a fresh random salt per password
03
Standard encoded string
Algorithm, parameters, salt and verifier live in one $argon2id$...
04
Verify at login
The library reads salt and settings from the string and checks safely
05
check_needs_rehash()
Detects whether a stored hash is outdated versus server policy
Important: the app never stores the pepper inside the output. Putting the pepper next to password_hash in the same table destroys the added layer entirely.

# Why Does the Same Password Hash Differently Every Time?

Add this experiment to the end of the file:
same_password_test.py
first = hash_password("same-password")
second = hash_password("same-password")
 
print(first)
print(second)
print("Different:", first != second)
 
print("First valid:", verify_password(first, "same-password")[0])
print("Second valid:", verify_password(second, "same-password")[0])
$argon2id$...$SALT_A$HASH_A
$argon2id$...$SALT_B$HASH_B
Different: True
First valid: True — Second valid: True
Odd at first glance: if the hashes differ, how can the program verify the password? The answer is inside the string itself. Every record stores its own salt. At verification the library extracts it, reprocesses the entered password with those same settings, and compares results.
One-liner to remember: Salt isn't secret; it's unique. Pepper isn't unique; it's secret. Combining these two different roles is the core design trick.

# What Happens When Argon2 Settings Change Later?

Hardware power doesn't stand still. A configuration that is appropriately expensive today may become cheap years from now. That's why the algorithm name and work factor must be stored alongside the hash. After a successful login, the app still has the raw password only in the current request's memory — that's the best moment to upgrade:
Automatic upgrade on successful login
Login success
Hash outdated?
Yes → rehash with new policy → update DB
That's exactly what check_needs_rehash() does in the sample. If the second value returned by verify_password() is a string, the application should replace the user's old hash with it. Users who never log in again won't be upgraded automatically; depending on system sensitivity, very old hashes can be expired with a forced password reset at next visit.

# What Do We Do with an Old Pepper?

It's wise to keep a non-secret pepper version next to each record:
users_table.txt
password_hash  = $argon2id$...
pepper_version  = 2
During a planned rotation, the application can recognize the old pepper for a limited window and rebuild each hash with the new pepper after a successful login. But if the old pepper has already leaked, keeping it around doesn't solve anything: accounts that haven't migrated must be force-reset.
This is a key salt/pepper difference: salt cannot be rotated without the password — but it doesn't need secret rotation, because it isn't a secret. Pepper is a secret, and its leak is a real security incident that demands real incident response.

# Migrating from Legacy SHA-256 or MD5

A legacy hash cannot be directly converted to Argon2id; the original password isn't inside it. The best migration path usually happens at login:
1
Detect the record's hash type
2
Legacy verify with the old scheme
3
Rehash with Argon2id + salt + pepper
4
Replace the stored hash
5
Retire the legacy path for that account
You can keep a prefix or a password_scheme column for legacy hashes:
schemes.txt
legacy-sha256$...
$argon2id$v=19$m=19456,t=2,p=1$...
Wrapping Argon2 around a legacy hash may temporarily raise attack cost, but it doesn't replace real migration with the original password. Inactive accounts should be force-reset after a defined grace period.

# Which Attacks Does This Design Withstand?

No password hash table should ever be labeled "unhackable". Security depends on what the attacker managed to obtain:
DB
Database only
Salts are public; Argon2id makes guesses expensive and a separate pepper adds another wall
DB+P
Database + Pepper
Offline attack becomes possible, but the Argon2id cost per guess remains
SRV
Live Application Server
Pepper and in-flight passwords may be reachable; incident response and secret rotation required
PWD
Weak, reused user passwords
Salt doesn't fix them; guessing probability stays high
ONL
Online attacks on the login form
Hashing alone isn't enough; rate limiting, MFA and anomaly detection are needed
Password hashing is designed mainly for the moment the hash file falls into attacker hands and they want offline guesses without touching the server. For online attacks, other controls are required.

# Common Mistakes That Break the Whole Design

Storing plaintext: any unauthorized read of the database, backup, logs, or admin panel becomes a direct password leak.
mistake_1.txt
password = "BlueTrain!2026"  
Reversible encryption of passwords: normal authentication never needs password recovery, and one master key is a huge single point of failure.
mistake_2.txt
AES(password, one_master_key)  
Using fast hashes: even SHA256(salt + password) is not deliberately slow or memory-hard.
mistake_3.txt
SHA256(password)  
MD5(password)     
SHA1(password)    
One global salt for all users: salt must be random and unique per hash; being fixed and public at the same time removes its main benefit.
mistake_4.txt
GLOBAL_SALT = "my-site-2026"  
Storing pepper next to the hash: any attacker who gets the database then holds both ingredients.
mistake_5.txt
users.password_hash
users.pepper  
Inventing your own crypto scheme: combining hashes, shuffling characters, or base64-encoding does not make a password hashing algorithm. Use well-known libraries and standard formats.
Logging passwords: the database may be perfectly designed while middleware, analytics, error trackers, or debug logs capture the login form body. Passwords must never enter logs, traces, crash reports, or error messages.
Forgetting TLS, rate limiting, and MFA: Argon2id protects leaked hashes; it does not stop eavesdropping, phishing, credential stuffing, or millions of online requests. The login form still needs HTTPS, attempt limits, suspicious-login alerts, and MFA for sensitive accounts.

# The Final Registration and Login Flow

Now we can put all the pieces together:
REGISTER
Password
HMAC-SHA256 with secret Pepper
Argon2id with random Salt + Work Factor
Encoded Verifier in Database
LOGIN
Entered password
HMAC-SHA256 with same Pepper
Argon2id with stored Salt + parameters
Safe Verify + Rehash if needed
Each piece solves a different problem:
HSH
Hash / KDF
Removes the need to keep the original password
SLT
Salt
Separates identical passwords from each other
WF
Work Factor
Makes every attacker guess expensive
MEM
Memory Cost
Makes massive parallel attacks costly
PPR
Pepper
Adds a secret outside the database
RH
Rehash
Enables future security upgrades

# We Don't Keep the Password; We Keep the Ability to Check It

The difference between a weak system and a correct design isn't the superficial complexity of the hash. A 64-character SHA-256 string can look very technical, but if it's fast, unsalted, and checkable with millions of guesses, its appearance doesn't help.
A correct design starts with a simple decision: the user's original password is not something the application will need later. We only need to check whether a new input is right or wrong. Salt keeps two identical passwords from looking identical. Argon2id raises the cost of every guess. Pepper moves part of the process outside the database. And storing the algorithm plus work factor lets the system improve over time.
The most important sentence might be this: a password hash is not a safe whose key we've lost. It's a deliberately expensive test that only says whether a new guess is consistent with the original password.
takeaway.txt
We don't encrypt passwords to get them back;
we turn them into a Verifier with Salt and a password hashing algorithm.

Salt is public and unique; Pepper is secret and lives outside the database.

Real security comes from combining these parts correctly.

# Related Posts