🧂 Salt, Pepper and Stretching: Password Hashing Dev Guide 2026
On this page
Salted password hashing implementation guide requests usually boil down to one question: how do I store a password so that a database breach does not become an account-takeover catastrophe? Salted password hashing answers that. It means running each user's password through a one-way, deliberately slow hash function (bcrypt or Argon2id) together with a unique random value called a salt. The salt guarantees that two users with the same password produce completely different hashes, which defeats precomputed rainbow tables and forces attackers to crack every hash individually. You never store the plaintext—only the salt and the resulting digest. This guide shows you exactly how to do it.
If you have ever written md5(password) or sha256(password) and shipped it, this article is your remediation plan. We will cover salts, peppers, stretching, work factor tuning, and production-ready code in both Python and Node.js, aligned with NIST SP 800-63B and OWASP guidance.
What Is Salt in Password Hashing?
A salt is a unique, cryptographically random value generated per password and stored alongside the hash. Before hashing, the salt is concatenated with (or fed into) the password input, so identical passwords yield different digests.
Why does this matter? Without a salt, an attacker who steals your database can compare your stored hashes against a precomputed table mapping common passwords to their hashes—a rainbow table. One lookup cracks every account that used hunter2. With a unique 16-byte salt per user, rainbow tables become useless: the attacker would need a separate table for every salt value, which is computationally infeasible.
Key properties of a correct salt:
- Unique per password—never reuse a global salt across users.
- Cryptographically random—generate it with a CSPRNG, not
Math.random(). (See our deep dive on why Math.random() is unfit for password generation.) - At least 16 bytes—128 bits of entropy is the modern floor.
- Stored in plaintext—the salt is not a secret. It only needs to be unique.
Good news: bcrypt and Argon2id generate and embed the salt for you. The encoded hash string contains the algorithm, parameters, salt, and digest in a single self-describing field. You do not manage salts manually.
Why Salt Alone Is Not Enough (Pepper)
Salt defeats precomputation, but it does nothing if an attacker has both your database and enough GPU time to brute-force weak passwords one salt at a time. This is where a pepper adds a second layer.
A pepper is a secret value—identical for all users—that is not stored in the database. It lives in a secrets manager, an environment variable, or an HSM. Two common implementations:
- Pre-hash HMAC: compute
HMAC-SHA256(password, pepper)before passing the result to bcrypt or Argon2id. - Post-hash encryption: encrypt the final hash with a key held outside the database.
The threat model is specific: a pepper protects you when an attacker exfiltrates the database but not the application secrets. If they breach your config store too, the pepper provides no extra protection—so treat it as defense-in-depth, not a substitute for a strong slow hash. Both OWASP and NCSC describe pepper as an optional supplementary control. CISA's secure-by-design guidance similarly emphasizes layering: salt is mandatory, pepper is a bonus, and key rotation matters more than people assume.
Work Factor and Stretching
Salting stops precomputation; stretching stops speed. Stretching (also called key stretching) means making the hash function deliberately expensive—thousands of internal iterations, large memory allocation, or both—so that each guess costs the attacker measurable time and hardware.
The tunable that controls this cost is the work factor. In bcrypt it is the cost parameter (a base-2 exponent for the number of rounds). In Argon2id it is a triple: time cost, memory cost, and parallelism. As CPUs and GPUs get faster, you increase these values. A hash that took 250ms in 2020 might take 40ms today—meaning attackers crack it 6x faster—so password hashing best practices 2026 require periodic retuning.
The calibration rule of thumb: target roughly 250ms–500ms per hash on your production hardware for interactive logins. Fast enough that users don't notice; slow enough that mass brute-forcing is uneconomical. Benchmark on the box that actually serves auth, not your laptop.
bcrypt vs Argon2id Implementation
bcrypt (1999, Blowfish-based) is battle-tested, ubiquitous, and has one notable quirk: it silently truncates input at 72 bytes. Argon2id (winner of the 2015 Password Hashing Competition) is the modern recommendation from OWASP and NIST because it is memory-hard—resistant to GPU and ASIC cracking in a way bcrypt is not. For a full head-to-head, read our companion piece: Argon2 vs bcrypt: a 2026 developer guide.
bcrypt in Python
import bcrypt
# Hashing on signup
def hash_password(password: str) -> bytes:
# cost=12 generates the salt automatically
salt = bcrypt.gensalt(rounds=12)
return bcrypt.hashpw(password.encode("utf-8"), salt)
# Verifying on login
def verify_password(password: str, stored_hash: bytes) -> bool:
return bcrypt.checkpw(password.encode("utf-8"), stored_hash)
bcrypt in Node.js
const bcrypt = require("bcrypt");
// Hashing on signup
async function hashPassword(password) {
const saltRounds = 12; // work factor
return bcrypt.hash(password, saltRounds);
}
// Verifying on login
async function verifyPassword(password, storedHash) {
return bcrypt.compare(password, storedHash);
}
Argon2id in Python
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher(
time_cost=3, # iterations
memory_cost=65536, # 64 MiB in KiB
parallelism=4, # lanes / threads
hash_len=32,
salt_len=16,
)
def hash_password(password: str) -> str:
return ph.hash(password) # salt generated and embedded automatically
def verify_password(stored_hash: str, password: str) -> bool:
try:
ph.verify(stored_hash, password)
if ph.check_needs_rehash(stored_hash):
# parameters increased since this hash was created
pass # re-hash and persist the new value
return True
except VerifyMismatchError:
return False
Argon2id in Node.js
const argon2 = require("argon2");
async function hashPassword(password) {
return argon2.hash(password, {
type: argon2.argon2id,
timeCost: 3,
memoryCost: 65536, // 64 MiB
parallelism: 4,
});
}
async function verifyPassword(storedHash, password) {
return argon2.verify(storedHash, password);
}
Key Configuration Parameters
The two algorithms expose different knobs. The table below summarizes sane production starting points for 2026; always benchmark and adjust toward your 250–500ms target.
| Parameter | bcrypt | Argon2id |
|---|---|---|
| Primary work factor | cost = 12 (approx 2^12 rounds) | time_cost = 3 iterations |
| Memory hardness | None (fixed 4 KiB) | memory_cost = 65536 KiB (64 MiB) |
| Parallelism | Not configurable | parallelism = 4 lanes |
| Salt | Auto, 16 bytes, embedded | Auto, 16 bytes, embedded |
| Output length | 60-char string | 32-byte digest (configurable) |
| Input length limit | 72 bytes (truncated!) | Effectively unlimited |
| GPU/ASIC resistance | Moderate | Strong (memory-hard) |
| OWASP 2026 stance | Acceptable for legacy | Recommended default |
OWASP's current minimum for Argon2id is m=19456 (19 MiB), t=2, p=1; the 64 MiB / t=3 configuration above is a stronger, server-grade baseline. For bcrypt, a cost of 10 is the floor and 12 is the recommended starting point for 2026.
Common Implementation Mistakes
- Rolling your own with SHA-256. General-purpose hashes are too fast. A modern GPU computes billions of SHA-256 hashes per second. Use a purpose-built slow hash.
- Reusing a global salt. A single shared salt reintroduces rainbow-table risk and lets attackers spot duplicate passwords.
- Ignoring bcrypt's 72-byte limit. Long passphrases get silently truncated. If you support long inputs with bcrypt, pre-hash with SHA-256 and base64-encode first—or switch to Argon2id.
- Never retuning the work factor. Hashes created in 2021 may now be too cheap. Use
check_needs_rehashand transparently upgrade on next successful login. - Comparing hashes with
==. Always use the library's constant-time comparison (checkpw,verify) to avoid timing side channels. - Capping or stripping password length/characters. NIST SP 800-63B says accept at least 64 characters and all Unicode. Truncation and composition rules hurt security.
- Logging the plaintext. Scrub passwords from request logs, error traces, and APM tooling.
If you are scripting credential generation or rotation from a terminal, our guide to Linux command-line password generation pairs well with the hashing patterns here. And for managing the secrets and peppers your application depends on, a dedicated secrets workflow like the 1Password CLI lets you inject peppers and database credentials at runtime without committing them to source control.
Frequently Asked Questions
Is salting still necessary if I use bcrypt or Argon2id?
Yes—but you do not implement it manually. Both algorithms automatically generate a unique random salt per password and embed it in the output string. Salting is mandatory and built in; you simply must not disable or override it with a static value.
Should I use a pepper in addition to a salt?
A pepper is an optional defense-in-depth layer. It helps only when an attacker steals your database but not your application secrets. Store the pepper outside the database (secrets manager or HSM). It is recommended by OWASP and NCSC as a supplement, never a replacement for a salted slow hash.
bcrypt or Argon2id—which should I choose in 2026?
Choose Argon2id for new projects: it is memory-hard and resistant to GPU/ASIC cracking, and it is the OWASP and NIST-aligned default. bcrypt remains acceptable for existing systems, but mind its 72-byte input limit. See our full Argon2 vs bcrypt comparison for the detailed trade-offs.
How do I choose the right work factor?
Benchmark on your production hardware and target roughly 250–500ms per hash for interactive logins. For bcrypt start at cost 12; for Argon2id start at t=3, 64 MiB memory, p=4. Re-measure annually and increase the parameters as hardware improves.
What does NIST SP 800-63B say about password storage?
NIST SP 800-63B requires that passwords (“memorized secrets”) be salted and hashed with a suitable one-way function using a cost factor, allow at least 64 characters and all printable Unicode, and avoid arbitrary composition rules. It also recommends checking new passwords against known-breached lists.
This page contains affiliate links. If you purchase through these links, we may earn a commission at no extra cost to you.