caduh

Password Storage the Right Way — salts, peppers, and modern hashes

4 min read

Use Argon2id (or bcrypt) with unique per‑user salts, optional KMS‑backed pepper, and safe on‑login migrations. Includes copy‑paste snippets for Node, Python, and Go.

TL;DR

  • Never store plaintext or fast hashes (MD5/SHA‑1/SHA‑256 alone). Use Argon2id (preferred) or bcrypt with a unique salt per user.
  • Tune cost so verify takes ~50–200ms on your hardware. Re‑benchmark over time.
  • Keep an optional pepper in KMS/secret manager, not in the DB or code.
  • Migrate legacy hashes on login; rehash to stronger params when needs_upgrade.
  • Add rate limiting, progressive delays, and encourage MFA—hashing isn’t your only control.

1) What to store (PHC string format)

Store the entire encoded hash (algorithm + params + salt + hash). Examples:

$argon2id$v=19$m=65536,t=3,p=1$BASE64_SALT$BASE64_HASH
$2b$12$Cj0qQJYbC7...$V4vSg7iJbT2mY...

Why: the verifier can read params and decide whether to rehash with newer settings.


2) Recommended algorithms & parameters

  • Argon2id — memory‑hard (resists GPU/ASIC cracking). Start around:
    • memoryCost: 64–256 MiB, timeCost: 2–4, parallelism: 1–4.
  • bcrypt — fine if widely supported in your stack. Set cost (log rounds) so verify ≈ 100ms. Avoid cost < 10 today.
  • PBKDF2 — acceptable in regulated stacks; use high iterations (e.g., 310k+) and consider migration.

Pick one algorithm per system; expose a feature flag or config to tweak parameters after benchmarking.


3) Code snippets you can paste

Node (argon2)

import argon2 from "argon2";

// Fetch from KMS/secret manager in prod
const PEPPER = process.env.PEPPER || "";

export async function hashPassword(pw: string): Promise<string> {
  return argon2.hash(pw + PEPPER, {
    type: argon2.argon2id,
    memoryCost: 1 << 16, // 64 MiB
    timeCost: 3,
    parallelism: 1,
  });
}

export async function verifyPassword(pw: string, encoded: string): Promise<boolean> {
  return argon2.verify(encoded, pw + PEPPER);
}

// Example "needs upgrade" check — rehash when params too weak
export async function needsUpgrade(encoded: string): Promise<boolean> {
  // argon2 doesn't expose built-in needsRehash; parse or store policy version
  const desired = { memoryCost: 1 << 16, timeCost: 3, parallelism: 1, type: argon2.argon2id };
  // Simple approach: try verify, and rehash when policy version column mismatches
  return false; // track via your own policy/version field
}

Python (argon2‑cffi)

from argon2 import PasswordHasher
import os

PEPPER = os.environ.get("PEPPER", "")
ph = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=1)

def hash_password(pw: str) -> str:
    return ph.hash(pw + PEPPER)

def verify_password(pw: str, encoded: str) -> bool:
    try:
        return ph.verify(encoded, pw + PEPPER)
    except Exception:
        return False

def needs_upgrade(encoded: str) -> bool:
    try:
        return ph.check_needs_rehash(encoded)
    except Exception:
        return True

Go (argon2id via x/crypto + scrypt-compatible encoder)

package auth

import (
  "crypto/rand"
  "encoding/base64"
  "fmt"
  "golang.org/x/crypto/argon2"
  "os"
)

var pepper = os.Getenv("PEPPER")

func HashPassword(pw string) (string, error) {
  salt := make([]byte, 16)
  if _, err := rand.Read(salt); err != nil { return "", err }
  // params
  m := uint32(64 * 1024) // 64 MiB
  t := uint32(3)
  p := uint8(1)
  hash := argon2.IDKey([]byte(pw+pepper), salt, t, m, p, 32)
  // PHC-ish encoding (simplified)
  return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
    m, t, p, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(hash)), nil
}

4) Peppering (optional, but helpful)

  • Pepper is a site‑wide secret concatenated with the password before hashing.
  • Store it in KMS/secret manager (or HSM), not the DB or repo. Rotate with care—support multiple active peppers for a window so old hashes can still verify.

Verify with multiple peppers (sketch)

const peppers = [PEPPER_CURRENT, PEPPER_OLD].filter(Boolean);
const ok = await Promise.any(peppers.map(p => argon2.verify(encoded, pw + p))).catch(() => false);

5) Migrating legacy hashes (no forced resets)

  1. Detect algorithm/cost by prefix/column.
  2. On login, verify with the old algorithm. If valid, rehash with Argon2id/better params and replace the stored hash.
  3. Mark a password_hash_version column so you can find rows still on legacy settings.

Node (bcrypt → argon2id) sketch

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

async function verifyAndMigrate(pw: string, user) {
  const hash = user.password_hash;
  const isBcrypt = hash.startsWith("$2a$") || hash.startsWith("$2b$");
  let ok = false;
  if (isBcrypt) ok = await bcrypt.compare(pw, hash);
  else ok = await argon2.verify(hash, pw + PEPPER);
  if (!ok) return false;
  if (isBcrypt || await needsUpgrade(hash)) {
    const newHash = await argon2.hash(pw + PEPPER, { type: argon2.argon2id, memoryCost: 1<<16, timeCost: 3, parallelism: 1 });
    await db.users.update(user.id, { password_hash: newHash, password_hash_version: "argon2id_64MB_t3_p1" });
  }
  return true;
}

6) Operational hygiene (beyond hashing)

  • Rate-limit login attempts per IP/user and add progressive delays.
  • Detect credential stuffing: many users from one IP / many IPs for one user; throttle & require CAPTCHA/MFA.
  • Don’t log raw passwords or full tokens. Redact secrets; use structured logs.
  • Password resets: single‑use signed tokens, short TTL (e.g., 15–30 min), invalidate on use. Never email passwords.
  • Encourage MFA (TOTP/WebAuthn) and device/session management.

7) Pitfalls & fast fixes

| Pitfall | Why it’s risky | Fix | |---|---|---| | Plaintext, MD5, SHA‑1/256 alone | Fast to crack offline | Use Argon2id or bcrypt | | Shared/global salt | Rainbow‑table friendly | Unique per‑user salt | | Pepper in source code | Repo leak = compromise | Keep pepper in KMS/HSM | | Under‑tuned cost | Cheap brute forcing | Benchmark to ~100ms verify | | Forcing mass resets | UX damage | On‑login migration | | Logging passwords/tokens | Secret leakage | Redact and lock down logs |


Quick checklist

  • [ ] Use Argon2id (or bcrypt) with per‑user salt.
  • [ ] Tune for ~100ms verify; re‑benchmark annually.
  • [ ] Optional pepper stored in KMS; support rotation.
  • [ ] Implement on‑login rehash upgrade path.
  • [ ] Add rate limiting and promote MFA.
  • [ ] Never log secrets; use structured logs and alerts.

One‑minute adoption plan

  1. Wrap hashing behind an interface; default to Argon2id with benchmarked params.
  2. Add password_hash_version and needsUpgrade checks.
  3. Wire on‑login migration from legacy hashes.
  4. Store pepper in KMS and enable rate limiting + progressive delays.
  5. Add MFA and a secure reset flow; monitor login anomaly metrics.