Password Hashing with bcrypt & argon2
Passwords are the most sensitive data most applications store, and storing them carelessly is the single fastest way to turn a minor breach into a catastrophe. The golden rule is that you never store a password you can recover — not in plain text and not encrypted. Instead you store a one-way hash produced by a slow, salted, purpose-built algorithm, and you verify logins by hashing the supplied password and comparing. This page covers why fast hashes are dangerous and how to use the two algorithms you should reach for today: bcrypt and argon2.
Why you hash instead of encrypt
Encryption is reversible by design: anyone with the key can recover the original value. That is exactly what you do not want for passwords, because the key — and therefore every password — becomes a single point of total failure. Hashing is one-way: given a hash you cannot feasibly recover the input, so even a full database leak does not directly expose passwords.
But not all hashes are equal. General-purpose hashes like MD5, SHA-1, or SHA-256 are built to be fast — billions of operations per second on a GPU — which is great for checksums and terrible for passwords. An attacker who steals your hashes can brute-force or use precomputed “rainbow tables” to recover weak passwords almost instantly. Password hashing functions are deliberately slow and memory-hard, turning each guess into an expensive operation.
Never use MD5, SHA-1, SHA-256, or
crypto.createHash()to store passwords. They are fast hashes and are trivially brute-forced. Use bcrypt or argon2.
Salting and work factor
Two properties make a password hash resistant to attack:
- Salt — a unique random value mixed into each hash so that two users with the same password produce different hashes. This defeats rainbow tables and prevents an attacker from cracking many accounts at once. Both bcrypt and argon2 generate and embed the salt for you automatically.
- Work factor (cost) — a tunable parameter that controls how slow the hash is. As hardware gets faster, you raise the cost so a single guess stays expensive. With bcrypt this is the “rounds” value (a logarithmic cost factor); with argon2 it is a combination of time, memory, and parallelism.
The salt is stored inside the resulting hash string, so you only need to persist that one string — no separate salt column required.
Hashing with bcrypt
bcrypt is battle-tested, widely supported, and a perfectly safe default. Install the native binding:
npm install bcrypt
import bcrypt from "bcrypt";
const SALT_ROUNDS = 12;
// Hash on registration
export async function hashPassword(plain) {
return bcrypt.hash(plain, SALT_ROUNDS);
}
// Verify on login
export async function verifyPassword(plain, hash) {
return bcrypt.compare(plain, hash);
}
const hash = await hashPassword("correct horse battery staple");
console.log(hash);
console.log(await verifyPassword("correct horse battery staple", hash));
console.log(await verifyPassword("wrong password", hash));
Output:
$2b$12$eIXp9k1q7m3a5xK0YbW0ZuQ1m2c4F8h6oR3pT7vL9dN0sK2jH5wG
true
false
The compare function re-hashes the candidate using the salt and cost embedded in the stored hash, then performs a constant-time comparison — so you never compare strings yourself.
A bcrypt hash always begins with
$2b$followed by the cost. Choose a cost so hashing takes roughly 250-500ms on your production hardware; benchmark it rather than guessing.
bcrypt truncates input at 72 bytes. If you accept very long passphrases, pre-hash with SHA-256 and base64-encode before passing to bcrypt, or use argon2, which has no such limit.
Hashing with argon2
argon2 won the Password Hashing Competition and is the modern recommendation, especially the argon2id variant, which resists both GPU and side-channel attacks. It is memory-hard, so it stays expensive even on specialized hardware.
npm install argon2
import argon2 from "argon2";
export async function hashPassword(plain) {
return argon2.hash(plain, {
type: argon2.argon2id,
memoryCost: 19456, // 19 MiB
timeCost: 2,
parallelism: 1,
});
}
export async function verifyPassword(hash, plain) {
return argon2.verify(hash, plain);
}
const hash = await hashPassword("correct horse battery staple");
console.log(hash);
console.log(await verifyPassword(hash, "correct horse battery staple"));
Output:
$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
true
Like bcrypt, argon2 embeds the variant, parameters, and salt in the output string, so verification needs nothing else. The OWASP-recommended baseline is argon2id with 19 MiB of memory, a time cost of 2, and parallelism of 1 — tune upward as your servers allow.
CommonJS works the same way:
const argon2 = require("argon2");. Both libraries expose identical async APIs under either module system.
bcrypt vs argon2
| Aspect | bcrypt | argon2 |
|---|---|---|
| Year / status | 1999, mature | 2015, modern winner |
| Memory-hard | No | Yes |
| Main parameter | Cost (rounds) | Memory, time, parallelism |
| Input length limit | 72 bytes | None |
| Recommended variant | $2b$ | argon2id |
| When to choose | Wide compatibility, proven | New projects, strongest defense |
Both are safe in 2026. Pick argon2id for new systems; bcrypt is fine if it is already in place or you need its ubiquity.
Upgrading work factors over time
Because the cost is stored in the hash, you can transparently re-hash on next login when you raise your parameters. After a successful verify, check whether the stored hash uses outdated settings and, if so, hash the freshly supplied plaintext and save it.
import argon2 from "argon2";
const OPTIONS = { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 };
export async function login(user, plain) {
if (!(await argon2.verify(user.passwordHash, plain))) return false;
if (argon2.needsRehash(user.passwordHash, OPTIONS)) {
user.passwordHash = await argon2.hash(plain, OPTIONS);
await user.save(); // persist the upgraded hash
}
return true;
}
Best practices
- Use argon2id for new projects, or bcrypt with a cost of 12+; never use fast hashes like SHA-256 or MD5 for passwords.
- Let the library generate the salt — it is embedded in the hash, so store only that single string.
- Benchmark your cost so a single hash takes ~250-500ms in production, and raise it as hardware improves.
- Always verify with the library’s
compare/verifyfunction so comparison is constant-time; never compare hashes with===. - Re-hash passwords on login when you increase work factors using
needsRehashso accounts stay current. - Enforce a minimum length over complexity rules, and run hashing on a worker or async so it never blocks the event loop under load.
- Never log, return, or transmit the plaintext or the hash; treat both as secrets.