JustAppSec
Back to guides

Secure Password Storage (bcrypt vs Argon2)

Password hashing is one of the most critical security decisions in any application. The choice between bcrypt and Argon2 matters less than configuring either one correctly. This guide covers both, along with what to avoid.

Never Store Plaintext Passwords

This should be obvious, but breaches still reveal plaintext databases. Hash every password before storage. No exceptions.

Never Use MD5, SHA-1, or SHA-256

General-purpose hash functions are designed to be fast. That is exactly what you do not want for passwords. A modern GPU can compute billions of SHA-256 hashes per second.

AlgorithmHashes per second (GPU)Time to crack 8-char password
MD5~60 billionSeconds
SHA-256~10 billionMinutes
bcrypt (cost 12)~30,000Years
Argon2id~1,000Centuries

Use a password hashing function — an algorithm specifically designed to be slow and resource-intensive.

bcrypt

The most widely deployed password hashing function. Battle-tested for over 25 years.

Implementation

import bcrypt from "bcrypt";

const SALT_ROUNDS = 12; // cost factor

// Hash a password
async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, SALT_ROUNDS);
}

// Verify a password
async function verifyPassword(
  password: string,
  hash: string
): Promise<boolean> {
  return bcrypt.compare(password, hash);
}

Choosing the Cost Factor

The cost factor (salt rounds) controls how many iterations bcrypt performs. Each increment doubles the work.

CostTime (approx)Use Case
10~100msMinimum acceptable
12~300msGood default
14~1sHigh security

Target 250-500ms per hash on your hardware. Benchmark on your production servers:

import bcrypt from "bcrypt";

async function benchmarkBcrypt() {
  for (let rounds = 10; rounds <= 16; rounds++) {
    const start = Date.now();
    await bcrypt.hash("test-password", rounds);
    console.log(`Cost ${rounds}: ${Date.now() - start}ms`);
  }
}

bcrypt Limitations

  • 72-byte input limit. bcrypt truncates passwords longer than 72 bytes. For most users this is fine, but if you accept passphrases, pre-hash with SHA-256:
import crypto from "crypto";
import bcrypt from "bcrypt";

async function hashLongPassword(password: string): Promise<string> {
  // Pre-hash to handle passwords > 72 bytes
  const prehash = crypto.createHash("sha256").update(password).digest("base64");
  return bcrypt.hash(prehash, 12);
}
  • CPU-only. bcrypt is CPU-hard but not memory-hard. GPUs and ASICs can be optimized for it (though it remains expensive).

Reference: OWASP — Password Storage Cheat Sheet

Argon2

The winner of the Password Hashing Competition (2015). Designed to be resistant to GPU and ASIC attacks by requiring large amounts of memory.

Variants

  • Argon2d: data-dependent memory access. Resistant to GPU cracking but vulnerable to side-channel attacks.
  • Argon2i: data-independent memory access. Resistant to side-channel attacks.
  • Argon2id: hybrid. Use this one.

Reference: RFC 9106 — Argon2

Implementation

import argon2 from "argon2";

// Hash a password
async function hashPassword(password: string): Promise<string> {
  return argon2.hash(password, {
    type: argon2.argon2id,   // Argon2id variant
    memoryCost: 65536,        // 64 MB
    timeCost: 3,              // 3 iterations
    parallelism: 4,           // 4 threads
  });
}

// Verify a password
async function verifyPassword(
  password: string,
  hash: string
): Promise<boolean> {
  return argon2.verify(hash, password);
}

Choosing Parameters

ParameterDescriptionRecommended
memoryCostMemory in KiB65536 (64 MB) minimum
timeCostNumber of iterations3 minimum
parallelismNumber of threads1-4
typeVariantargon2id

OWASP recommendation: Argon2id with 19 MiB memory, iteration count of 2, and 1 degree of parallelism as minimum. Increase memory as high as your server allows while keeping hash time under 1 second.

Reference: OWASP — Password Storage Cheat Sheet (Argon2id)

Benchmark on Your Hardware

import argon2 from "argon2";

async function benchmarkArgon2() {
  const configs = [
    { memoryCost: 19456, timeCost: 2, parallelism: 1 },   // OWASP minimum
    { memoryCost: 65536, timeCost: 3, parallelism: 4 },   // moderate
    { memoryCost: 131072, timeCost: 4, parallelism: 4 },  // high
  ];

  for (const config of configs) {
    const start = Date.now();
    await argon2.hash("test-password", { type: argon2.argon2id, ...config });
    console.log(`${JSON.stringify(config)}: ${Date.now() - start}ms`);
  }
}

bcrypt vs Argon2: When to Choose Which

FactorbcryptArgon2id
Maturity25+ years10 years
GPU resistanceCPU-hard onlyCPU + memory-hard
Library supportEverywhereMost languages
ComplianceWidely acceptedIncreasingly recommended
Max input length72 bytesNo practical limit
RecommendationGood defaultBest choice for new projects

For new projects, use Argon2id. It provides stronger guarantees against hardware-accelerated attacks.

bcrypt is still acceptable and significantly better than any non-password-hashing algorithm. If your ecosystem has better bcrypt support, use it.

Upgrading Hash Algorithms

If you are migrating from bcrypt to Argon2 (or increasing cost parameters), re-hash on login:

async function loginAndUpgradeHash(
  email: string,
  password: string
): Promise<User | null> {
  const user = await db.user.findUnique({ where: { email } });
  if (!user) return null;

  // Verify against current hash
  const valid = user.passwordHash.startsWith("$argon2")
    ? await argon2.verify(user.passwordHash, password)
    : await bcrypt.compare(password, user.passwordHash);

  if (!valid) return null;

  // Re-hash with current algorithm if needed
  if (!user.passwordHash.startsWith("$argon2")) {
    const newHash = await argon2.hash(password, {
      type: argon2.argon2id,
      memoryCost: 65536,
      timeCost: 3,
      parallelism: 4,
    });
    await db.user.update({
      where: { id: user.id },
      data: { passwordHash: newHash },
    });
  }

  return user;
}

Additional Defenses

Pepper

A pepper is a secret key applied before hashing. Unlike the salt (which is stored with the hash), the pepper is stored separately (environment variable or HSM).

const PEPPER = process.env.PASSWORD_PEPPER!;

async function hashWithPepper(password: string): Promise<string> {
  const peppered = crypto
    .createHmac("sha256", PEPPER)
    .update(password)
    .digest("base64");
  return argon2.hash(peppered, { type: argon2.argon2id, memoryCost: 65536 });
}

If the database is breached but the pepper is not, the hashes are significantly harder to crack.

Password Strength Requirements

  • Minimum 8 characters (NIST recommends supporting up to 64).
  • Check against known breached passwords (Have I Been Pwned API).
  • Do not enforce composition rules (uppercase + special character) — they do not improve security and annoy users.

Reference: NIST SP 800-63B — Digital Identity Guidelines

Checklist

  • Using bcrypt (cost 12+) or Argon2id (not MD5, SHA-1, SHA-256)
  • Hash time benchmarked on production hardware (target 250ms–1s)
  • Salt generated automatically by the library (bcrypt and Argon2 do this)
  • Pepper applied before hashing (stored outside the database)
  • No password length maximum under 64 characters
  • Breached password check integrated (Have I Been Pwned)
  • Hash upgrade path implemented (re-hash on login)

Related Guides


Content is AI-assisted and reviewed by our team, but issues may be missed and best practices evolve rapidly, send corrections to [email protected]. Always consult official documentation and validate key implementation decisions before making design or security choices.

Need help?Get in touch.