Why passwords need special handling
Every other secret in your system you can rotate: API keys, tokens, certificates. Passwords are different. You never hold them in a form you can regenerate, users reuse them across sites, and the moment your database leaks, the attacker has all the time in the world to guess offline.
Password hashing is the practice of storing a one-way transformation of the password instead of the password itself. When a user signs in, you apply the same transformation and compare. You never learn the password, and neither does anyone who steals the table.
This is not a place for cleverness. The rules are small and well established, and breaking them is how breaches become catastrophic.
Hashing is not encryption
Encryption is reversible. If you encrypt passwords, the key lives somewhere in your system, and anyone who obtains it — through a leak, a backup, a misconfigured environment variable or an insider — can read every password at once. There is no scenario where you need the original password, so reversibility is pure risk.
A hash function takes input and produces a fixed-length digest, and there is no practical way back. For passwords, a plain hash is still not enough: fast functions like SHA-256 are designed to be quick, which is exactly what an attacker wants. A GPU can try billions of candidates per second.
You need a function that is deliberately slow and memory-hard, so guessing is expensive at scale.
Salt: the same password, a different hash
Without a salt, two users with the password hunter2 produce the same digest. An attacker can precompute a giant table of common passwords and their hashes — a rainbow table — and look up matches instantly. They can also see at a glance which accounts share a password.
A salt is a unique random value generated per password and stored alongside the digest. It is not secret; its only job is to make every hash unique. Now the attacker must attack each account separately, and precomputation is useless.
Modern libraries generate the salt for you and encode it, with the parameters, into a single string. You store that string verbatim.
Choosing an algorithm
Three functions are worth knowing. All are adaptive: the cost can be raised as hardware improves.
Argon2id
The winner of the 2015 Password Hashing Competition and the current recommendation. It is memory-hard, which means an attacker must buy memory as well as compute, blunting the advantage of GPUs and ASICs. Argon2id balances resistance to side-channel and GPU attacks and is the right default for new systems.
scrypt
Standardised in RFC 7914 and memory-hard, scrypt is a solid choice when Argon2 is unavailable. It exposes cost, block size and parallelism, so it can be tuned, but the parameters are easier to misconfigure than Argon2’s.
bcrypt
Released in 1999 and still everywhere, bcrypt is well understood and battle-tested. Its cost factor doubles the work with each increment. The main caveat is that it truncates input at 72 bytes, so very long passphrases are silently cut — pre-hash if that matters to you.
Whichever you choose, never reach for MD5, SHA-1 or plain SHA-256. They are the wrong tool, and stacking rounds on top of them is a homemade scheme nobody should trust.
Tuning the work factor
The cost must balance two pressures. Too low and the hash falls to brute force; too high and a burst of login attempts becomes a denial-of-service vector against your own CPU.
Tune it so a single hash takes roughly 100 to 500 milliseconds on your production hardware. Measure the login endpoint under realistic load, not just a benchmark loop, and set the number from data. Then revisit it periodically: the same parameters get weaker every year as hardware improves.
// Argon2id parameters (memory in KiB, iterations, parallelism)
await hash(password, { memoryCost: 19456, timeCost: 2, parallelism: 1 });
Storing and verifying
Registration and login are the only two places you touch a password.
import { hash, verify } from "@node-rs/argon2";
// Registration: hash before the password ever reaches the database.
const digest = await hash(password);
await db.users.insert({ email, passwordHash: digest });
// Login: verify against the stored digest.
const user = await db.users.findByEmail(email);
const ok = user && (await verify(user.passwordHash, password));
if (!ok) return res.status(401).json({ error: "invalid_credentials" });
Return the same error whether the email or the password was wrong. A message like “no such user” hands an attacker a way to enumerate accounts.
Timing attacks and safe comparison
Comparing two hashes with === stops at the first differing byte. The time it takes therefore depends on how much of the guess was correct, and a patient attacker can measure that to reconstruct a value. It is a narrow channel, but password comparison is exactly where it matters.
Use the library’s verification function, which compares in constant time. Never compare digests yourself, and never compare the raw password.
Upgrading hashes over time
You will change your mind: the cost goes up, or you migrate from bcrypt to Argon2. Because you never stored the plaintext, you can only rehash at login, when the password is briefly in memory.
Store the algorithm and parameters with each digest — the encoded string already does — and check them on every successful login:
if (await verify(stored, password)) {
if (needsRehash(stored)) {
await db.users.update(user.id, { passwordHash: await hash(password) });
}
// continue the login
}
Users who never sign in again keep their old hash, which is fine; it still protects them. Over time the active accounts migrate naturally.
Password policy in practice
The algorithm protects you after a breach. Policy reduces the chance of one.
- Prefer length over complexity. A passphrase beats
P@ssw0rd!. - Check new passwords against known-breached lists such as Have I Been Pwned’s k-anonymity API.
- Do not impose arbitrary rotation. Forced expiry leads to
Summer2026!patterns. - Rate-limit login and reset attempts, and add exponential backoff or a lockout.
- Use a generic reset flow with single-use, expiring tokens, and never email a password.
Best practices
- Hash with Argon2id, scrypt or bcrypt — never a fast general-purpose hash.
- Let the library generate and store the salt.
- Tune the cost to 100–500 ms and raise it over time.
- Verify with the library’s constant-time function.
- Rehash on successful login when parameters change.
- Return generic errors and rate-limit attempts.
- Keep hashing on the server; never in the client.
Common mistakes
- Encrypting passwords or storing them in plaintext “temporarily”.
- Using SHA-256 or MD5 with a few rounds and calling it hashing.
- A single global salt, or reusing one salt across accounts.
- Comparing hashes with
==or===. - Logging passwords or including them in error reports.
- Capping password length at a low value, which hurts passphrases.
- Forgetting to upgrade the cost factor for years.
Where to go next
Credentials are only the first gate. Once a user is verified, you need somewhere to keep that state: read the Session Auth guide for cookie-based sessions, or the JWT guide for stateless tokens. When you would rather not handle passwords at all, OAuth 2.0 and OpenID Connect delegate login to an identity provider.