Skip to content
Node.js nd security 5 min read

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

Aspectbcryptargon2
Year / status1999, mature2015, modern winner
Memory-hardNoYes
Main parameterCost (rounds)Memory, time, parallelism
Input length limit72 bytesNone
Recommended variant$2b$argon2id
When to chooseWide compatibility, provenNew 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/verify function so comparison is constant-time; never compare hashes with ===.
  • Re-hash passwords on login when you increase work factors using needsRehash so 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.
Last updated June 14, 2026
Was this helpful?