Published: · Updated: · by kodewright
Salting and Hashing: A Developer's Practical Guide (2026)
How to store passwords in 2026: per-user salts, Argon2id or bcrypt settings from OWASP, real RTX 4090 numbers, peppers, and migrating old hashes.

To store passwords safely in 2026, run them through a slow, salted password hashing function: Argon2id with at least 19 MiB of memory, 2 iterations and 1 lane (the OWASP minimum), or bcrypt at cost 10 or higher if that's what your stack already has. Let the library generate a fresh random salt per password and store the whole encoded string it returns. Salting alone doesn't make a fast hash like SHA-256 safe, and most of the mistakes I see in auth code come from mixing those two ideas up.
What salting adds to hashing, and what it doesn't
A salt is a random value generated for each password and fed into the hash along with it. It's stored next to the hash in plaintext, because the verifier needs it and secrecy isn't its job. RFC 9106 recommends 128 bits (16 bytes) for Argon2; NIST SP 800-63B sets the floor at 32 bits.

A unique salt does two things:
- Precomputed tables (rainbow tables, or plain lookup databases of hash → password) become useless, because nobody precomputed hashes for your random salt.
- The attacker has to attack each hash separately. Without salts, one pass of a wordlist over a stolen table tests every user at once, and two users with the same password share a hash, which leaks that fact by itself.
What a salt does not do is slow down guessing against a single hash. If the function is SHA-256, the attacker takes one user's salt, prepends it to each candidate, and runs at full GPU speed. Salting multiplies the work by the number of users; the hash function decides how expensive each guess is.
Why a salted SHA-256 still falls fast
General-purpose hashes are built to be fast. These are hashcat 6.2.6 benchmarks on a single RTX 4090:
| Function | Guesses per second (one RTX 4090) | Meant for passwords? |
|---|---|---|
| MD5 | 164.1 billion | No |
| SHA-1 | 50.6 billion | No |
| SHA-256 | 21.98 billion | No |
| SHA-512 | 7.48 billion | No |
| PBKDF2-HMAC-SHA256, 600,000 iterations | ~14,800 (scaled from the 999-iteration benchmark) | Yes |
| bcrypt, cost 10 | ~5,750 (derived from the cost-5 benchmark) | Yes |
| bcrypt, cost 12 | ~1,440 | Yes |
Real attackers don't brute-force random strings; they run wordlists with mangling rules. Take a modest run of 10 billion candidates (say, a 10-million-word list with 1,000 rules) against one stolen hash:
| Stored as | Time for 10 billion guesses, one RTX 4090 |
|---|---|
| salted MD5 | about 0.06 seconds |
| salted SHA-256 | about 0.5 seconds |
| PBKDF2-SHA256, 600k iterations | about 8 days |
| bcrypt cost 10 | about 20 days |
| bcrypt cost 12 | about 80 days |
That's per user, per GPU. Attackers rent rigs with eight or more cards, so divide accordingly. The point is the ratio: the slow functions make each guess hundreds of thousands to millions of times more expensive, and that gap is what gives your users time to change passwords after a breach. A weak password like Summer2024! sits early in every wordlist and falls quickly against any of these, which is why the hashing choice protects the middle of the distribution, not the worst passwords.
Choosing the algorithm and its parameters
NIST SP 800-63B (Rev. 4) doesn't name an algorithm. It requires passwords to be "salted and hashed using a suitable password hashing scheme", a salt of at least 32 bits, and a cost factor "as high as practical without negatively impacting verifier performance." The OWASP Password Storage Cheat Sheet is more concrete, so I use its numbers:
| Algorithm | OWASP minimum | When I'd pick it |
|---|---|---|
| Argon2id | m=19 MiB, t=2, p=1 (or m=46 MiB, t=1, p=1) | Default for new code |
| scrypt | N=2¹⁷ (128 MiB), r=8, p=1 | Argon2id isn't available in your runtime |
| bcrypt | cost 10 or higher, as high as the server allows | Existing systems, or a framework that defaults to it |
| PBKDF2-HMAC-SHA256 | 600,000 iterations | You need FIPS-validated primitives |
Argon2id is memory-hard: every guess has to fill and read the configured amount of memory, so the attacker pays in RAM as well as compute. bcrypt's working state is fixed at about 4 KiB, no matter what cost you set. That's the main reason Argon2id is the better default, not some weakness in bcrypt itself.
These are minimums. Measure on your production hardware and raise the cost until a single hash takes as long as your login endpoint can afford under load. Remember that an attacker hitting your login form forces you to pay that cost too, so the endpoint needs rate limiting regardless.
Argon2id in Node.js, with the rehash bug avoided
The argon2 package defaults to Argon2id with 64 MiB, t=3, p=4, which matches the second recommended option in RFC 9106 and sits above the OWASP floor. If you change parameters, keep them in one object and pass that same object everywhere:
import argon2 from "argon2";
const HASH_OPTS = {
type: argon2.argon2id,
memoryCost: 19456, // KiB = 19 MiB, the OWASP minimum
timeCost: 2,
parallelism: 1,
};
export async function hashPassword(password) {
// Salt is generated inside; the result embeds algorithm, params and salt:
// $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>
return argon2.hash(password, HASH_OPTS);
}
export async function checkPassword(user, password) {
const ok = await argon2.verify(user.passwordHash, password);
if (ok && argon2.needsRehash(user.passwordHash, HASH_OPTS)) {
await db.users.updateHash(user.id, await argon2.hash(password, HASH_OPTS));
}
return ok;
}
The reason for the shared object: needsRehash() fills any option you leave out with the library default. Hash with parallelism: 1, call needsRehash(hash, { memoryCost: 19456, timeCost: 2 }) without parallelism, and it compares p=1 to the default p=4 and returns true on every login. Nothing breaks visibly, you just rewrite the hash on every sign-in. I've seen published snippets with exactly that mismatch, including one that used to be on this blog.
verify() reads the salt and parameters from the stored string and compares with Node's timingSafeEqual, so don't extract or compare anything yourself, and don't keep a separate salt column.
The 72-byte limit in bcrypt, and pre-hashing safely
Most bcrypt implementations use only the first 72 bytes of input and ignore the rest without an error. In UTF-8 that's 36 Cyrillic letters or 18 emoji, not 72 characters.
That limit has burned real systems. In October 2024 Okta disclosed that its AD/LDAP delegated authentication built a cache key by running bcrypt over userId + username + password. With a username of 52 characters or more, the password fell past byte 72 and didn't affect the key, so a cached login could succeed with any password (MFA off and a few other conditions also had to apply). Okta fixed it by moving that cache key to PBKDF2.
If you need longer inputs, the usual trick is to pre-hash, and the naive version is dangerous. A raw SHA-256 digest can contain a 0x00 byte, and bcrypt implementations that treat input as a C string stop there. A plain bcrypt(sha256(password)) also allows "password shucking": an attacker with a leaked unsalted SHA-256 of the same password from another breach can test it directly against your bcrypt hash. OWASP's construction avoids both:
bcrypt(base64(HMAC-SHA384(key = pepper, data = password)), salt, cost)
Base64 removes null bytes, and the HMAC key (a pepper, see below) blocks shucking. For a new project I'd rather switch to Argon2id than maintain this.
A pepper helps only when the secret lives somewhere else
A pepper is a secret key applied to every password, stored outside the database. NIST SP 800-63B says verifiers SHOULD add "an additional iteration of a keyed hashing or encryption operation using a secret key known only to the verifier", kept separately from the hashes and ideally inside an HSM.
The threat it covers is narrow: the database leaks (SQL injection, a stolen backup) but the application secrets don't. If the attacker owns the server, they have both. Two ways to apply it:
- Post-hash: store
HMAC-SHA256(pepper, argon2id_hash). Easy to bolt onto an existing table. - Pre-hash: feed
HMAC(pepper, password)into the KDF, as in the bcrypt construction above.
Plan for rotation before you ship it. OWASP points out that a pepper can't be changed without the users' passwords, so replacing a leaked one means forcing resets, and keeping old peppers around under a version id until users log in again.
What I check first when reviewing password code
Most of my hands-on security work is code review rather than writing auth systems from scratch, and these are the problems that are quick to spot:
- One salt for everyone, often a constant in a config file. It's barely better than no salt: identical passwords still collide and one wordlist pass covers the whole table.
- Salt from
Math.random(). V8'sMath.random()is xorshift128+, and its internal state can be recovered from a handful of outputs. Usecrypto.randomBytes(16)in Node orcrypto.getRandomValues()in the browser, or simply let the KDF library generate the salt. sha256(salt + password)described as "salted hashing". It is salted, and it still runs at 22 billion guesses per second per GPU.- Comparing hashes with
===. Let the library'sverify/comparedo it. - Request bodies in logs. A debug logger that dumps the login payload puts plaintext passwords in your log storage, bypassing all of the above. Redact password fields before logging.
- No path to raise the cost. If the parameters are hard-coded and nothing rehashes on login, they stay at 2019 levels forever.
Moving an existing table to a better hash
You can't rehash what you can't read, so migrations have two parts. First, on every successful login, verify against the old hash, then store a fresh Argon2id hash of the password the user just typed. Second, for users who never come back, wrap the old hashes right away: argon2id(md5_hash), with a flag per row so the login code knows to compute md5(password) first. OWASP calls this layering and notes it's a stopgap; replace layered hashes with direct ones at the next login, and consider forcing a reset for accounts that stay dormant.
If you want to see the speed gap yourself, hash a test string in the Hash Generator, which runs entirely in your browser and never sends the input anywhere: SHA-256 output appears as you type. Then try the same string in the Bcrypt Generator and move the cost slider from 10 to 14 to watch the per-hash time estimate climb. For the bcrypt internals and the MD5 history, read what bcrypt is and why MD5 is dead; for what these speeds mean from the user's side, see how long it takes to crack a password.
FAQ
What is the difference between hashing and salting?
Hashing is the one-way function that turns a password into a digest. Salting is the random per-password value mixed in before hashing. The salt makes identical passwords produce different hashes and forces the attacker to crack every account separately, but it doesn't slow down guesses against any one hash.
Is SHA-256 safe for password hashing?
No. It's a sound hash for integrity checks and signatures, but one RTX 4090 computes about 22 billion SHA-256 hashes per second, salt or no salt. A 10-billion-guess wordlist attack against one salted SHA-256 hash takes about half a second; against bcrypt cost 10 it takes about 20 days on the same card.
Should I use Argon2id or bcrypt in 2026?
Use Argon2id for new code, starting at m=19 MiB, t=2, p=1, or the argon2 package defaults (64 MiB, t=3, p=4) if your servers can afford them. Keep bcrypt if it's already in place and the cost is at least 10; move to Argon2id through rehash-on-login when you next touch the auth code.
What is a pepper and do I need one?
A pepper is a secret key, the same for all users, applied with HMAC before or after the password hash and kept outside the database. It's worth adding if your secrets live in a vault or HSM that a database leak wouldn't expose. If an attacker would get the database and the environment variables together, it adds little.