<!-- PkgPulse AI-readable guide source -->
<!-- Canonical: https://www.pkgpulse.com/guides/bcrypt-vs-argon2-vs-scrypt-password-hashing-2026 -->
<!-- Raw Markdown: https://www.pkgpulse.com/guides/bcrypt-vs-argon2-vs-scrypt-password-hashing-2026/raw.md -->
<!-- Source path: content/guides/bcrypt-vs-argon2-vs-scrypt-password-hashing-2026.mdx -->

---
og_image: "/images/guides/bcrypt-vs-argon2-vs-scrypt-password-hashing-2026.webp"
title: "bcrypt vs argon2 vs scrypt: Password Hashing 2026"
description: "bcrypt, Argon2id, and scrypt compared for Node.js password hashing. Security tradeoffs, OWASP settings, serverless tuning, and which algorithm to use in 2026."
date: "2026-03-09"
author: "PkgPulse Team"
tags: ["security", "authentication", "nodejs", "typescript"]
tier: 2
---

## 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.

---

## Download Trends

| Package | Weekly Downloads | Language | Notes |
|---------|-----------------|----------|-------|
| `bcryptjs` | ~3.2M | Pure JavaScript | Slower, no native deps |
| `bcrypt` | ~1.8M | C++ bindings | Faster, requires node-gyp |
| `argon2` | ~850K | C++ bindings | OWASP recommended |
| `@node-rs/argon2` | ~120K | Rust via napi-rs | Fastest Argon2 impl |
| `scrypt-js` | ~4.1M | Pure JavaScript | Used 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.

```typescript
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.

   ```typescript
   // 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](https://github.com/P-H-C/phc-winner-argon2) won the Password Hashing Competition in 2015, specifically designed to address bcrypt's limitations.

```typescript
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:

```typescript
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

| Property | bcrypt | Argon2id | scrypt |
|----------|--------|----------|--------|
| OWASP recommended | ⭐⭐⭐ (2nd tier) | ⭐⭐⭐⭐⭐ (1st tier) | ⭐⭐⭐⭐ (2nd tier) |
| Memory-hard | ❌ | ✅ | ✅ |
| GPU-resistant | ⚠️ Moderate | ✅ Strong | ✅ Strong |
| Max password length | 72 bytes | None | None |
| Salt handling | Auto-embedded | Auto-embedded | Manual |
| Standardized | No formal RFC | RFC 9106 | RFC 7914 |
| Peer review | 25+ years | 10 years (PHC) | 15+ years |
| Tuning | Cost factor only | Memory + time + threads | Cost + block + threads |

---

## Performance Benchmarks

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

| Algorithm | Settings | Time | Memory Used |
|-----------|---------|------|-------------|
| bcrypt | cost=10 | ~65ms | <1MB |
| bcrypt | cost=12 | ~250ms | <1MB |
| Argon2id | m=64MB, t=3 | ~180ms | 64MB |
| Argon2id | m=19MB, t=2 | ~55ms | 19MB |
| scrypt | N=32768 | ~120ms | 32MB |

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

---

## Recommended Settings by Use Case

```typescript
// 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:

```typescript
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

---

## Timing Attacks and Constant-Time Comparison

Password verification has a security subtlety that's easy to miss: comparing the result of a hash to a stored hash with regular string equality (`===`) leaks timing information. A naive comparison returns early as soon as it finds the first non-matching character, which means an attacker who measures response times precisely can determine which characters match — effectively guessing the hash byte by byte.

All three libraries solve this at the library level: `bcrypt.compare()`, `argon2.verify()`, and Node.js's `crypto.timingSafeEqual()` use constant-time comparison, meaning the function always takes the same amount of time regardless of how much of the comparison has matched. This is not optional security hygiene — it's the difference between a correct implementation and one that leaks authentication data through timing side channels.

The practical rule: never use `storedHash === computedHash` or string `.equals()` for comparing authentication-sensitive values. Use the library's verify function, which handles constant-time comparison internally. When implementing custom verification (for example, scrypt where you manage the format), always wrap the final buffer comparison in `crypto.timingSafeEqual()`.

```typescript
// WRONG — timing oracle (returns early on first mismatch):
const isValid = bcrypt.hashSync(plaintext, 10) === storedHash

// CORRECT — constant-time comparison:
const isValid = await bcrypt.compare(plaintext, storedHash)
const isValid = await argon2.verify(storedHash, plaintext)

// For scrypt or custom implementations:
const isValid = crypto.timingSafeEqual(
  Buffer.from(computedHash, "hex"),
  Buffer.from(storedHash, "hex")
)
```

---

## Serverless and Edge Deployments: Tuning for Constrained Environments

Password hashing creates a specific problem for serverless environments: Argon2id's `memoryCost` parameter allocates memory for each hash operation. The OWASP-recommended `memoryCost: 65536` (64MB) is a meaningful fraction of the memory available in a default AWS Lambda (128MB) or Cloudflare Workers (128MB) function. Hash a password during memory pressure and your function may error or hit cold-start memory limits.

The solution is to tune down `memoryCost` for constrained runtimes while keeping `timeCost` stable. On Lambda or Workers, `memoryCost: 12288` (12MB) with `timeCost: 3` still provides meaningful resistance against GPU attacks while fitting comfortably within default limits. For Lambda functions that handle authentication, increasing function memory to at least 256MB is the better long-term fix — the cost increase is minimal (roughly $0.0000000066 per GB-second) and the headroom is significant.

```typescript
// Lambda / Cloudflare Workers — memory-constrained setting:
const SERVERLESS_SETTINGS = {
  type: argon2.argon2id,
  memoryCost: 12288,  // 12MB — safe for 128MB function memory
  timeCost: 3,
  parallelism: 1,
}

// Preferred: increase function memory and use standard settings
// AWS Lambda → 256MB memory configuration
// Cloudflare Workers → upgrade to Workers Unbound for higher limits
const PREFERRED_SETTINGS = {
  type: argon2.argon2id,
  memoryCost: 65536,  // OWASP minimum — use if function has 256MB+
  timeCost: 3,
  parallelism: 4,
}
```

bcrypt doesn't have a memory cost parameter, which makes its resource usage predictable in serverless environments — a cost=12 bcrypt hash uses less than 1MB and takes the same time regardless of available memory. This is one reason bcrypt remains a practical choice for authentication in serverless-first architectures even though Argon2id is the stronger algorithm. If memory tuning across deployment environments is a concern, bcrypt's predictable footprint reduces operational risk. Run a benchmark on your specific Lambda configuration before settling on final parameter values — acceptable latency under load matters as much as theoretical security levels.

---

## 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 →](https://www.pkgpulse.com/compare/bcrypt-vs-argon2)*

*See also: [jose vs jsonwebtoken vs fast-jwt: JWT for Node.js 2026](/guides/jose-vs-jsonwebtoken-vs-fast-jwt-jwt-libraries-nodejs-2026) and [SuperTokens vs Hanko vs Authelia](/guides/supertokens-vs-hanko-vs-authelia-self-hosted-2026), [Node.js Crypto vs @noble/hashes vs crypto-js](/guides/nodejs-crypto-vs-noble-hashes-vs-crypto-js-javascript-2026).*
