If your app stores passwords, this decision matters more than almost anything else in your codebase. Get it right, and a database breach is an inconvenience. Get it wrong, and it's a front-page problem.
Let's break down the two most popular password hashing algorithms - bcrypt and Argon2 - in plain language, then walk through exactly how to use each one in Node.js.
First, a quick refresher: what is password hashing?
You never want to store a user's actual password in your database. Instead, you run it through a hashing function - a one-way scramble that turns "MyPassword123" into something like $2b$12$KIXQ.... There's no "unscramble" button. When someone logs in, you hash what they typed and check if it matches the stored hash.
This is different from encryption, which can be reversed with a key. Hashing is meant to be a dead end - even for you, the developer.
But here's the catch: a fast hash is a bad password hash. If an attacker steals your database, a fast algorithm lets them try billions of guesses per second. That's why bcrypt and Argon2 exist - they're deliberately, tunably slow.
Bcrypt: the veteran
Bcrypt has been around since 1999, built on the Blowfish cipher by Niels Provos and David Mazières. It's the algorithm most developers reach for by default, and for good reason - it's had over two decades of scrutiny with no practical breaks found.
How it works: Bcrypt uses a "cost factor" (also called salt rounds) - a number like 10 or 12 that determines how many times the algorithm iterates internally. Each increment doubles the work, so cost 12 takes roughly twice as long as cost 11.
Where it falls short:
It caps passwords at 72 bytes - anything longer gets silently truncated, which can be a subtle bug if you don't handle it.
It's "CPU-hard" but not "memory-hard." It only needs a small, fixed amount of memory no matter how high you set the cost factor. That means attackers with modern GPU or ASIC hardware can run many parallel guesses efficiently - something that wasn't a real threat in 1999 but is very real today.
Is it still safe? Yes, mostly. At a cost factor of 12 or higher, bcrypt is still considered acceptable for production use in 2026. It's just no longer the strongest option available.
Argon2: the modern standard
Argon2 won the Password Hashing Competition in 2015 - an open competition specifically designed to find bcrypt's successor - and was formally standardized as RFC 9106 in 2021. It's now the top recommendation in OWASP's Password Storage Cheat Sheet.
Argon2 comes in three flavors:
Argon2d - fastest, but more exposed to side-channel timing attacks.
Argon2i - designed to resist side-channel attacks, slightly slower.
Argon2id - a hybrid of both, and the one you should almost always use.
What makes it different: Argon2 is memory-hard. Instead of just tuning "how many rounds," you tune three separate dials:
Memory cost - how much RAM each hash requires (e.g., 19–64 MiB)
Time cost - how many iterations to run
Parallelism - how many threads run in parallel
Forcing an attacker to allocate real memory for every guess is what makes Argon2 so much harder to brute-force at scale than bcrypt - GPUs and ASICs are built to be fast and cheap on memory-light workloads, not memory-heavy ones.
Argon2 vs bcrypt: head to head
Bcrypt | Argon2id | |
|---|---|---|
Released | 1999 | 2015 (RFC 9106 in 2021) |
Resistance to GPU/ASIC attacks | Weaker - low, fixed memory use | Strong - memory-hard by design |
Tunable parameters | 1 (cost factor) | 3 (memory, time, parallelism) |
Password length limit | 72 bytes (silent truncation) | No practical limit |
OWASP recommendation | Acceptable for existing systems | Preferred for new systems |
Ecosystem maturity | Extremely wide - every language, every stack | Strong and growing, slightly newer bindings |
Best for | Legacy systems, environments needing broad compatibility | New projects, anything security-sensitive |
So which one should you pick?
Use bcrypt if:
You already have a bcrypt-hashed user base and your cost factor is 12 or higher - there's no urgency to migrate.
You're working in an environment with limited or inconsistent Argon2 library support.
You're not a high-value target (e.g., not a financial platform or password manager).
Use Argon2id if:
You're building something new. This is the easy default answer in 2026.
You're in a high-value or high-risk category - fintech, healthcare, anything holding sensitive data.
You want a future-proof setup you can simply "turn up" (more memory, more time) as hardware improves.
One important caveat if you're deploying to serverless environments (AWS Lambda, Cloudflare Workers, etc.): Argon2's memory requirement is real memory your function has to reserve. On a default 128 MB function, OWASP's standard 64 MiB profile eats half your available memory. In that case, scale the memory cost down (or bump your function's memory allocation) rather than abandoning Argon2 altogether.
Migrating from bcrypt to Argon2 later? You don't need to force a password reset. The standard pattern is rehash on login: verify the password with bcrypt as usual, and if it succeeds, immediately re-hash the plaintext password with Argon2id and save the new hash. Over a few months, your active users migrate themselves.
How to use both in Node.js
Option 1: Bcrypt
Install the native package:
npm install bcrypt
Hashing a password (on signup):
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12; // 12 is a solid default for 2026
async function hashPassword(plainPassword) {
return await bcrypt.hash(plainPassword, SALT_ROUNDS);
}
Verifying a password (on login):
async function verifyPassword(plainPassword, storedHash) {
return await bcrypt.compare(plainPassword, storedHash);
}
Putting it together in a signup/login flow:
// Signup
app.post('/signup', async (req, res) => {
const { email, password } = req.body;
const hash = await hashPassword(password);
await db.users.create({ email, passwordHash: hash });
res.status(201).send('Account created');
});
// Login
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.users.findByEmail(email);
if (!user || !(await verifyPassword(password, user.passwordHash))) {
return res.status(401).send('Invalid credentials');
}
res.send('Logged in');
});
A couple of things worth knowing:
The salt is generated automatically and stored inside the returned hash string - you don't need a separate salt column in your database.
If a compiler/build toolchain is a problem for your deployment (e.g., some serverless or Alpine-based containers), the pure-JavaScript
bcryptjspackage is a drop-in alternative - about 30% slower, but no native build step required.Bcrypt silently truncates anything past 72 bytes. If you allow long passwords, pre-hash with SHA-256 before passing it to bcrypt so nothing gets cut off unexpectedly.
Option 2: Argon2
Install the package:
npm install argon2
Hashing a password (on signup):
const argon2 = require('argon2');
// OWASP-recommended baseline configuration
const hashOptions = {
type: argon2.argon2id,
memoryCost: 19456, // 19 MiB - OWASP minimum
timeCost: 2, // iterations
parallelism: 1,
};
async function hashPassword(plainPassword) {
return await argon2.hash(plainPassword, hashOptions);
}
Verifying a password (on login):
async function verifyPassword(plainPassword, storedHash) {
try {
return await argon2.verify(storedHash, plainPassword);
} catch {
return false;
}
}
Checking if an old hash needs upgrading (handy during a migration or after you raise your parameters):
async function needsRehash(storedHash) {
return argon2.needsRehash(storedHash, hashOptions);
}
// During login, after a successful verify:
if (await needsRehash(user.passwordHash)) {
const newHash = await hashPassword(plainPassword);
await db.users.updatePasswordHash(user.id, newHash);
}
Like bcrypt, the salt and all the parameters (memory, time, parallelism) are embedded directly in the returned hash string, so verification always uses the exact settings the hash was created with - even if you change your defaults later.
Choosing your parameters: OWASP publishes a couple of acceptable profiles. A common starting point for a typical web server:
Profile | Memory | Time | Parallelism | Roughly |
|---|---|---|---|---|
OWASP minimum | 19 MiB | 2 | 1 | ~40ms per hash |
OWASP standard | 64 MiB | 3 | 1 | ~90–100ms per hash |
High-security | 128 MiB | 4 | 2 | ~300ms+ per hash |
Benchmark on your actual production hardware and pick the heaviest setting that still keeps login feeling instant - generally under 300–500ms. If you expect a lot of concurrent logins, remember to multiply your memory cost by your peak concurrent login count to make sure you're not going to run your server out of RAM.
The bottom line
New project? Reach for Argon2id. It's the current OWASP recommendation, it's memory-hard, and its parameters give you room to grow as hardware gets faster.
Existing bcrypt system running at cost 12+? You're not in danger - there's no need to rush a migration. Just make sure new signups eventually move over, using the rehash-on-login pattern.
Either way: never write your own hashing logic. Use a well-maintained library, keep your parameters aligned with current OWASP guidance, and revisit your settings every year or so as hardware evolves.
Password hashing isn't a "set it and forget it" decision - but it's one of the few security choices where getting it right the first time saves you a lot of pain later.


