← Back to the library
SecurityHow it works · Applied · 11 min

Storing passwords: Argon2id, salts, peppers and rehashing

How should a server store passwords so that a stolen database does not become a list of working logins?

Store a slow, salted, memory-hard hash of each password (Argon2id by default) together with its parameters, verify by recomputing it, and upgrade old hashes when users next log in. Encryption is the wrong tool, fast hashes are the wrong tool, and passkeys remove the problem altogether.

THE MENTAL MODEL

Assume the database leaks. From that moment the attacker guesses offline, on GPUs, as fast as your hash allows, so your only lever is the cost of one guess. A password hash is a deliberately expensive one-way function: the salt makes every user's hash a separate target, memory hardness makes GPU and ASIC parallelism expensive, and the work parameters let you raise the price as hardware gets cheaper. Encryption fails this model because the key sits next to the data and decrypts every password at once. Everything around the hash (rejecting breached passwords, a pepper kept outside the database, rehashing on login, equal timing for unknown users) narrows what the attacker can do with the guesses they can still afford.

HOW IT FITS TOGETHER

Signup and login with rehashThe plaintext exists only in memory during these steps. Rehashing can only happen at login, because that is the only time you have it.
  1. Validate lengthMinimum 15 characters, or 8 with MFA (OWASP, after NIST); allow at least 64 characters, Unicode and spaces, no composition rules. Cap input size (for example 1 KB) so hashing cannot be used for DoS.
  2. Reject breached passwordsk-anonymity range query: send the first 5 hex characters of the SHA-1, compare suffixes locally. Also block your app's name and obvious variants.
  3. Hash with Argon2idFresh random 16-byte salt from the library, OWASP parameters or stronger, optional pepper from a secrets manager.
  4. Store the PHC string$argon2id$v=19$m=19456,t=2,p=1$salt$hash keeps algorithm and parameters with each hash, so mixed generations can coexist.
  5. Login: look up the userIf the account does not exist, verify against a dummy hash anyway so response time does not reveal which emails are registered.
  6. Verify in constant timeRecompute with the stored salt and parameters; the library compares in constant time. Rate-limit per account and per IP before this step.
  7. Rehash if parameters are staleneedsRehash / check_needs_rehash / password_needs_rehash: if the stored algorithm or cost is below current policy, hash again and update the row.
  8. Start a fresh sessionRegenerate the session ID, then discard the plaintext. Never log request bodies on auth routes.
Password hashing algorithmsParameters are the OWASP Password Storage Cheat Sheet minimums. Tune upward until a single hash takes what your login capacity allows.
Memory-hardOWASP minimumLimits and trapsUse when
Argon2idYes, tunablem=19 MiB, t=2, p=1 (or 12 MiB t=3, 7 MiB t=5)Memory cost x concurrent logins must fit in RAMDefault for new systems
scryptYesN=2^17 (128 MiB), r=8, p=1Memory and CPU cost coupled; easy to misconfigureArgon2id is unavailable
bcryptNo, but GPU-unfriendlyCost 10 minimum; 12+ is commonUses only the first 72 bytes; NUL bytes truncate in some librariesExisting systems; plan migration
PBKDF2-HMAC-SHA256No; cheap on GPUs600,000 iterations (SHA-512: 220,000)Weakest per unit of server timeFIPS-140 compliance is required
SHA-256 / MD5, saltedNoNot acceptableBillions of guesses per second per GPUNever for passwords
AES encryptionNot applicableNot acceptableOne key leak reveals every password at onceNever; you never need the plaintext back

KEY TERMS

Hashing, not encryption
Verification only needs to answer "does this input produce the stored value", so nothing should be able to recover the password. Encryption is reversible by design; the key is on the same servers, in the same backups, and in the same breach.
Slow, memory-hard hashes
Argon2id, scrypt and bcrypt make each guess cost CPU time, and Argon2id and scrypt also RAM, which is what GPUs and ASICs lack per core. OWASP's Argon2id minimum is 19 MiB, 2 iterations, parallelism 1; argon2-cffi defaults to RFC 9106's 64 MiB, t=3, p=4 profile and PHP to 64 MiB, t=4, p=1, both stronger. Budget memory x concurrent logins.
Salts
A unique random value per hash (16 bytes is standard), stored in the clear inside the encoded hash. It defeats precomputed tables and forces the attacker to attack each user separately. Modern libraries generate it for you; never supply your own or reuse one.
Peppers
A secret shared by all hashes, kept in a secrets manager or HSM, never in the database. It helps only when the database leaks without the application secrets. Argon2 accepts it as the secret (K) input; with bcrypt, OWASP's suggestion is HMAC-SHA-384 with the pepper, base64-encoded, before hashing. Rotating it requires a password reset, so version it.
Rehash on login
Store algorithm and parameters with each hash (PHC string or a {id} prefix). After a successful verify, if the hash is older than current policy, hash the plaintext again and save it. For dormant accounts on a broken scheme, wrap the old hash (argon2id(md5)) now and replace it on next login, or expire the password.
Breached-password checks
Credential stuffing uses passwords leaked elsewhere, which no hash strength stops. HIBP Pwned Passwords takes only the first 5 hex characters of the SHA-1 (k-anonymity) and returns roughly 800 matching suffixes with counts; Add-Padding: true hides the true response size. Check at signup and password change.
Prefer passkeys
A passkey leaves your server with only a public key, so there is nothing to crack, stuff or phish. Offer passkeys first and keep passwords as a fallback; then the fallback and the account recovery path become the parts to harden.

IN YOUR STACK

TypeScript · Node.js argon2 0.45 Server

import * as argon2 from "argon2";

// OWASP minimum; raise memoryCost while p99 login latency allows.
const PARAMS = { type: argon2.argon2id, memoryCost: 19_456, timeCost: 2, parallelism: 1 } as const;
const PEPPER = Buffer.from(process.env.PASSWORD_PEPPER_V1!, "base64"); // from a secrets manager
const DUMMY_HASH = await argon2.hash("timing-equalizer", { ...PARAMS, secret: PEPPER });

export const hashPassword = (password: string) =>
  argon2.hash(password, { ...PARAMS, secret: PEPPER });

export async function verifyLogin(email: string, password: string) {
  const user = await db.users.findByEmail(email);
  // Always run one verify so unknown emails take as long as wrong passwords.
  const ok = await argon2.verify(user?.passwordHash ?? DUMMY_HASH, password, { secret: PEPPER });
  if (!user || !ok) return null;

  if (argon2.needsRehash(user.passwordHash, PARAMS)) {
    await db.users.update(user.id, { passwordHash: await hashPassword(password) });
  }
  return user;
}
  • verify takes the stored hash first, then the plaintext. The salt is generated and embedded for you.
  • Hashing runs on the libuv thread pool (4 threads by default). Bound concurrent hashes and rate-limit logins, or a burst of attempts starves every other async fs and crypto call.
  • @node-rs/argon2 is a Rust-based alternative with prebuilt binaries and a similar hash/verify API.
Node.js argon2 0.45 documentation (opens in new tab)

Python · argon2-cffi Server

from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError

# Defaults: Argon2id, RFC 9106 low-memory profile (64 MiB, t=3, p=4), above OWASP's minimum.
ph = PasswordHasher()
DUMMY_HASH = ph.hash("timing-equalizer")


def hash_password(password: str) -> str:
    return ph.hash(password)


def verify_login(email: str, password: str):
    user = users.find_by_email(email)
    try:
        ph.verify(user.password_hash if user else DUMMY_HASH, password)
    except (VerificationError, InvalidHashError):  # VerifyMismatchError is a VerificationError
        return None
    if user is None:
        return None
    if ph.check_needs_rehash(user.password_hash):
        users.update(user.id, password_hash=ph.hash(password))
    return user
  • verify(hash, password) returns True or raises; it never returns False, so if not ph.verify(...) without try turns every wrong password into an unhandled exception (a 500) instead of a clean rejection.
  • Lower the profile with PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1) if 64 MiB per login is too much for your workers.
  • Django and many frameworks wrap this library; prefer the framework's hasher setting so rehash-on-login is automatic.
argon2-cffi documentation (opens in new tab)

Go · golang.org/x/crypto/argon2 Server

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

const memoryKiB, iterations, threads, keyLen = 19 * 1024, 2, 1, 32
var b64 = base64.RawStdEncoding

func Hash(password string) string {
	salt := make([]byte, 16)
	rand.Read(salt) // never returns an error since Go 1.24
	key := argon2.IDKey([]byte(password), salt, iterations, memoryKiB, threads, keyLen)
	return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version,
		memoryKiB, iterations, threads, b64.EncodeToString(salt), b64.EncodeToString(key))
}

// p is parsed from the stored PHC string by your decodePHC helper.
func Verify(password string, p PHC) (ok, needsRehash bool) {
	got := argon2.IDKey([]byte(password), p.Salt, p.T, p.M, p.P, uint32(len(p.Key)))
	ok = subtle.ConstantTimeCompare(got, p.Key) == 1
	return ok, ok && (p.M < memoryKiB || p.T < iterations)
}
  • x/crypto/argon2 is a primitive: it returns raw key bytes and leaves encoding, parsing and comparison to you. Always use subtle.ConstantTimeCompare, never bytes.Equal.
  • Parameters live in the encoded string so Verify uses the stored values, not the current constants; that is what makes rehashing possible.
  • The package doc quotes RFC 9106's 2 GiB first recommendation; that is for dedicated KDF use, not a login endpoint serving many users at once.
golang.org/x/crypto/argon2 documentation (opens in new tab)

Java · Spring Security Server

import java.util.Map;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.DelegatingPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
class PasswordConfig {
  @Bean
  PasswordEncoder passwordEncoder() {
    // saltLength, hashLength, parallelism, memory (KiB), iterations: OWASP minimum.
    var argon2 = new Argon2PasswordEncoder(16, 32, 1, 19_456, 2);
    Map<String, PasswordEncoder> encoders = Map.of(
        "argon2@owasp", argon2,
        "bcrypt", new BCryptPasswordEncoder(12)); // existing {bcrypt} hashes keep working
    return new DelegatingPasswordEncoder("argon2@owasp", encoders);
  }
}
// New hashes are stored as {argon2@owasp}$argon2id$... With a UserDetailsPasswordService
// bean, a successful login re-encodes any hash whose {id} differs from the current one.
  • Argon2PasswordEncoder needs Bouncy Castle (org.bouncycastle:bcprov-jdk18on) on the classpath.
  • defaultsForSpringSecurity_v5_8() uses 16 MiB and 2 iterations, slightly below OWASP's 19 MiB minimum; pass explicit parameters instead.
  • Unknown or missing {id} prefixes throw IllegalArgumentException on matches; migrate legacy rows by prefixing their id rather than mapping a default.
Spring Security documentation (opens in new tab)

PHP · password_hash (PHP 8.4) Server

<?php
const PASSWORD_OPTIONS = ['memory_cost' => 19456, 'time_cost' => 2, 'threads' => 1];
// A precomputed Argon2id hash of a random string, used when the email is unknown.
const DUMMY_HASH = '$argon2id$v=19$m=19456,t=2,p=1$...';

function hash_password(string $password): string {
    return password_hash($password, PASSWORD_ARGON2ID, PASSWORD_OPTIONS);
}

function verify_login(PDO $db, string $email, string $password): ?array {
    $stmt = $db->prepare('SELECT id, password_hash FROM users WHERE email = ?');
    $stmt->execute([$email]);
    $user = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
    $hash = $user['password_hash'] ?? DUMMY_HASH;

    if (!password_verify($password, $hash) || $user === null) {
        return null;
    }
    // True for old bcrypt ($2y$) hashes too, so this migrates them on login.
    if (password_needs_rehash($hash, PASSWORD_ARGON2ID, PASSWORD_OPTIONS)) {
        $db->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
           ->execute([hash_password($password), $user['id']]);
    }
    return $user;
}
  • PASSWORD_ARGON2ID exists only if PHP was built with Argon2 (libargon2 or sodium); with the sodium implementation the threads option is unavailable.
  • PASSWORD_DEFAULT is still bcrypt; PHP 8.4 raised its default cost from 10 to 12, and bcrypt truncates input at 72 bytes.
  • Store hashes in a VARCHAR(255) column: algorithms and encoded lengths change over time.
password_hash (PHP 8.4) documentation (opens in new tab)

TypeScript · HIBP Pwned Passwords API Server

import { createHash } from "node:crypto";

export async function breachCount(password: string): Promise<number> {
  const sha1 = createHash("sha1").update(password, "utf8").digest("hex").toUpperCase();
  const prefix = sha1.slice(0, 5); // the only part that leaves your server
  const suffix = sha1.slice(5);

  const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {
    headers: { "Add-Padding": "true", "User-Agent": "example-app-password-check" },
    signal: AbortSignal.timeout(2_000),
  });
  if (!res.ok) throw new Error(`Pwned Passwords returned ${res.status}`);

  for (const line of (await res.text()).split("\n")) {
    const [candidate, count] = line.trim().split(":");
    if (candidate === suffix) return Number(count); // padding rows have count 0
  }
  return 0;
}

// At signup and password change, never at login:
// if ((await breachCount(newPassword).catch(() => 0)) > 0) reject("Found in a known breach");
  • SHA-1 here is a lookup key, not storage. The plaintext and the full hash never leave your server; only a 5-character prefix shared by hundreds of passwords does.
  • No API key or rate limit for Pwned Passwords, but a User-Agent header is required. Decide deliberately whether an outage fails open (shown) or blocks signups.
  • Better Auth's haveIBeenPwned plugin and Spring Security's HaveIBeenPwnedRestApiPasswordChecker wrap the same range API.
HIBP Pwned Passwords API documentation (opens in new tab)

WHEN TO REACH FOR WHAT

Argon2id

Use when Any new system, or a migration target; tune memory to what concurrent logins can afford.

Avoid when A FIPS-140 validated module is mandatory, or the platform has no maintained binding.

scrypt

Use when Argon2id is unavailable but scrypt is (Node's crypto.scrypt, many standard libraries).

Avoid when Argon2id is available; it is easier to parameterize correctly.

bcrypt (cost 12+)

Use when Keeping an existing bcrypt database secure while you rehash users to Argon2id on login.

Avoid when Starting fresh, or passphrases may exceed 72 bytes and the library truncates silently.

PBKDF2-HMAC-SHA256

Use when Compliance requires FIPS-approved algorithms; use 600,000+ iterations.

Avoid when You have a choice; it offers the least resistance per millisecond of server time.

Passkeys, password as fallback

Use when Consumer or workforce apps on platforms with passkey support; removes stored secrets and phishing.

Avoid when You cannot also harden recovery; a weak reset flow becomes the new front door.

WHERE IT BITES

  • Hashing with SHA-256 or MD5, even salted and iterated a few times. Fast hashes fall to GPU cracking within hours for most real passwords.
  • Pre-hashing for bcrypt with raw SHA output: the binary digest can contain NUL bytes that truncate the input, and an unkeyed pre-hash enables password shucking against other leaked SHA databases.
  • Returning early for unknown emails. The missing hash computation makes account enumeration a timing measurement.
  • Choosing parameters without load testing. 64 MiB per hash times a burst of 200 logins is 12.8 GiB; attackers can turn the login endpoint into a memory DoS.
  • Storing the pepper in the same database or config table as the hashes, or rotating it without a version id and forcing every user into a reset.
  • Never rehashing. Parameters chosen in 2018 stay in your database forever unless verify-time upgrades and expiry of dormant accounts move them forward.

CLOSE THE AI. EXPLAIN THIS.

You inherit a table of unsalted MD5 hashes and must move to Argon2id today without forcing a reset. Describe the wrapping step, what happens at each user's next login, how you handle accounts that never log in again, and how you prevent timing-based email enumeration.

SOURCES

Explainer reviewed