Skip to main content

bcrypt vs argon2 vs scrypt: Password Hashing in 2026

·PkgPulse Team

TL;DR

For new projects in 2026: use Argon2id. OWASP, NIST, and virtually every security authority recommends it as the gold standard for password hashing. bcrypt is still safe but limited to 72-byte passwords and was designed in 1999. scrypt is solid but less widely reviewed than Argon2. If you're on bcrypt today, it's fine to stay — but new code should default to Argon2id.

Key Takeaways

  • Argon2id: OWASP's #1 recommendation — winner of the Password Hashing Competition (PHC), memory-hard, GPU-resistant
  • bcrypt: Still safe, proven over 25 years, but 72-byte max and no parallelism parameter
  • scrypt: Memory-hard like Argon2 but fewer tuning options and less peer-reviewed
  • npm packages: bcrypt (~1.8M/wk), bcryptjs (~3.2M/wk pure-JS, slower), argon2 (~850K/wk)
  • Never use: MD5, SHA-1, SHA-256 for passwords — they're too fast (built for speed, not security)
  • All three are fine for existing production systems — priority for new code: Argon2id

The Fundamental Rule

Password hashing is not the same as regular hashing (SHA-256, MD5). A password hash needs to be:

  1. Slow on purpose — to resist brute-force attacks
  2. Memory-hard — to defeat GPU/ASIC acceleration
  3. Salted — to prevent rainbow table attacks

SHA-256 can hash 1 billion passwords per second on a GPU. bcrypt can hash ~20. Argon2id can hash ~1 (with proper settings). That difference is the entire security story.


PackageWeekly DownloadsLanguageNotes
bcryptjs~3.2MPure JavaScriptSlower, no native deps
bcrypt~1.8MC++ bindingsFaster, requires node-gyp
argon2~850KC++ bindingsOWASP recommended
@node-rs/argon2~120KRust via napi-rsFastest Argon2 impl
scrypt-js~4.1MPure JavaScriptUsed by ethers.js (wallets)

scrypt-js downloads are inflated by crypto/wallet libraries, not password hashing use cases.


bcrypt

bcrypt was designed in 1999 and has been scrutinized by the security community for 25+ years. No significant vulnerabilities have been found.

import bcrypt from "bcrypt"

// Hashing (async — never use sync in request handlers)
const SALT_ROUNDS = 12 // 2^12 iterations — ~250ms on modern hardware

async function hashPassword(plaintext: string): Promise<string> {
  return bcrypt.hash(plaintext, SALT_ROUNDS)
}

async function verifyPassword(
  plaintext: string,
  hash: string
): Promise<boolean> {
  return bcrypt.compare(plaintext, hash)
}

// Usage:
const hash = await hashPassword("hunter2")
// "$2b$12$..." — bcrypt hash with embedded salt and cost factor

const valid = await verifyPassword("hunter2", hash)  // true
const invalid = await verifyPassword("wrong", hash)   // false

bcrypt limitations:

  1. 72-byte truncation: bcrypt silently truncates passwords to 72 bytes. "password_with_very_long_suffix_that_exceeds_72_bytes_total" and "password_with_very_long_suffix_that_exceeds_72_bytes_alter" hash to the same value.

    // Mitigation: pre-hash with SHA-256 (not ideal but works)
    import { createHash } from "crypto"
    
    function preprocess(password: string): string {
      return createHash("sha256").update(password).digest("hex")
    }
    // Then: bcrypt.hash(preprocess(password), SALT_ROUNDS)
    
  2. No memory-hardness: Modern GPUs can run many bcrypt instances in parallel. Cost factor 12 provides good protection today but may weaken as hardware improves.

  3. Cost factor only: Single parameter to tune speed — no separate memory or parallelism controls.

When bcrypt is fine:

  • Existing production systems — don't migrate just to migrate
  • Projects where native C++ dependencies are a problem (use bcryptjs instead)
  • Teams where bcrypt expertise exists and Argon2 is unfamiliar

Argon2

Argon2 won the Password Hashing Competition in 2015, specifically designed to address bcrypt's limitations.

import argon2 from "argon2"

// Argon2id (the recommended variant — hybrid of Argon2i and Argon2d)
async function hashPassword(plaintext: string): Promise<string> {
  return argon2.hash(plaintext, {
    type: argon2.argon2id,
    memoryCost: 65536,  // 64 MB — OWASP minimum recommendation
    timeCost: 3,        // iterations
    parallelism: 4,     // threads
  })
}

async function verifyPassword(
  plaintext: string,
  hash: string
): Promise<boolean> {
  return argon2.verify(hash, plaintext)
}

// Hash format: $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>
// All parameters are embedded — verify() auto-reads them

Three Argon2 variants:

  • Argon2id — Use this. Hybrid of the other two, recommended by OWASP for password hashing
  • Argon2i — Optimized against side-channel attacks; used for password-based key derivation (not password storage)
  • Argon2d — Maximizes GPU resistance; not recommended for password hashing (vulnerable to side-channel)

OWASP 2026 minimum settings:

Argon2id: m=19456 (19MB), t=2, p=1  — fast devices (login endpoints)
Argon2id: m=65536 (64MB), t=3, p=4  — higher security contexts

Argon2 advantages over bcrypt:

  1. No password length limit
  2. Memory-hard — resistant to GPU/ASIC attacks
  3. Three tunable parameters: memory, time, parallelism
  4. PHC winner — extensively peer-reviewed since 2015
  5. RFC 9106 standardized (2021)

scrypt

scrypt is the memory-hard algorithm used in Litecoin and many cryptocurrency wallets. Node.js has it built in:

import { scrypt, randomBytes, timingSafeEqual } from "crypto"
import { promisify } from "util"

const scryptAsync = promisify(scrypt)

// OWASP recommended parameters: N=32768 (2^15), r=8, p=1
const N = 32768  // CPU/memory cost
const r = 8      // block size
const p = 1      // parallelism
const KEYLEN = 64

async function hashPassword(plaintext: string): Promise<string> {
  const salt = randomBytes(32).toString("hex")
  const derivedKey = await scryptAsync(plaintext, salt, KEYLEN, { N, r, p }) as Buffer
  return `${salt}:${derivedKey.toString("hex")}`
}

async function verifyPassword(
  plaintext: string,
  storedHash: string
): Promise<boolean> {
  const [salt, hash] = storedHash.split(":")
  const derivedKey = await scryptAsync(plaintext, salt, KEYLEN, { N, r, p }) as Buffer
  const storedKey = Buffer.from(hash, "hex")
  return timingSafeEqual(derivedKey, storedKey)
}

Note: scrypt has no standard hash encoding format — you must store salt separately (as shown above) or implement your own format. This is a footgun compared to bcrypt and Argon2, which embed salt in the hash string.

scrypt compared to Argon2:

  • Less peer-reviewed than Argon2
  • Memory requirement = N * r * 128 bytes — less intuitive to reason about
  • No recommended parameters from PHC (there was no competition winner)
  • Still technically sound, widely used in crypto wallets

Security Comparison

PropertybcryptArgon2idscrypt
OWASP recommended⭐⭐⭐ (2nd tier)⭐⭐⭐⭐⭐ (1st tier)⭐⭐⭐⭐ (2nd tier)
Memory-hard
GPU-resistant⚠️ Moderate✅ Strong✅ Strong
Max password length72 bytesNoneNone
Salt handlingAuto-embeddedAuto-embeddedManual
StandardizedNo formal RFCRFC 9106RFC 7914
Peer review25+ years10 years (PHC)15+ years
TuningCost factor onlyMemory + time + threadsCost + block + threads

Performance Benchmarks

Approximate timing on modern hardware (Apple M3, measuring one hash operation):

AlgorithmSettingsTimeMemory Used
bcryptcost=10~65ms<1MB
bcryptcost=12~250ms<1MB
Argon2idm=64MB, t=3~180ms64MB
Argon2idm=19MB, t=2~55ms19MB
scryptN=32768~120ms32MB

Target: 100–300ms per hash operation is the accepted sweet spot for login endpoints.


// Login endpoint (high-volume, need responsiveness):
const LOGIN_SETTINGS = {
  type: argon2.argon2id,
  memoryCost: 19456,  // 19MB
  timeCost: 2,
  parallelism: 1,
}

// Admin/high-value accounts (can afford more time):
const ADMIN_SETTINGS = {
  type: argon2.argon2id,
  memoryCost: 65536,  // 64MB
  timeCost: 3,
  parallelism: 4,
}

// Password reset / account creation (one-time, latency acceptable):
const HIGH_SECURITY_SETTINGS = {
  type: argon2.argon2id,
  memoryCost: 131072,  // 128MB
  timeCost: 4,
  parallelism: 4,
}

Migrating from bcrypt to Argon2

Don't rehash all passwords at once — use a lazy migration on login:

import bcrypt from "bcrypt"
import argon2 from "argon2"

async function loginUser(plaintext: string, storedHash: string) {
  let verified = false

  if (storedHash.startsWith("$2b$") || storedHash.startsWith("$2a$")) {
    // Legacy bcrypt hash
    verified = await bcrypt.compare(plaintext, storedHash)

    if (verified) {
      // Upgrade to Argon2id on successful login
      const newHash = await argon2.hash(plaintext, {
        type: argon2.argon2id,
        memoryCost: 65536,
        timeCost: 3,
        parallelism: 4,
      })
      await updateUserPasswordHash(userId, newHash)
    }
  } else if (storedHash.startsWith("$argon2")) {
    // New Argon2 hash
    verified = await argon2.verify(storedHash, plaintext)
  }

  return verified
}

This migrates users transparently over time with zero downtime.


When to Use Each

Choose Argon2id if:

  • Starting any new project in 2026
  • You want OWASP-compliant password storage
  • You need fine-grained tuning (memory, time, parallelism)
  • You want protection against GPU/ASIC brute-force

Choose bcrypt if:

  • You're maintaining an existing bcrypt-based system
  • Native C++ dependencies are blocked (use bcryptjs)
  • Your passwords are guaranteed to be under 72 bytes (or you pre-hash)

Use scrypt if:

  • You're building something in the cryptocurrency/Web3 space (convention)
  • You specifically need the built-in Node.js crypto module (no external deps)

Never use for passwords:

  • MD5, SHA-1, SHA-256, SHA-512 — too fast, designed for data integrity not passwords
  • PBKDF2 — technically OWASP-compliant but inferior to Argon2 and bcrypt

Methodology

Security recommendations sourced from OWASP Password Storage Cheat Sheet (2025 update), NIST SP 800-63B, and RFC 9106 (Argon2). Download counts represent npm weekly averages (February 2026). Performance benchmarks measured with node:crypto and argon2 npm package on Apple M3 hardware.

Compare bcrypt vs argon2 package health on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.