In short

Hash passwords with Argon2id using at least 19 MiB of memory, 2 passes and 1 lane, a random salt per password and the PHC string format, so the parameters travel with each hash. Use scrypt if Argon2id is unavailable, PBKDF2-HMAC-SHA256 with 600,000 iterations when FIPS 140 validation is required, and bcrypt (cost 10 or more, 72-byte input limit) only where it is already deployed. Rehash after each successful login whose hash is below policy.

  • Never store passwords with a fast hash such as SHA-256 or with reversible encryption.
  • OWASP minimums: Argon2id m=19456,t=2,p=1; scrypt N=2^17,r=8,p=1; PBKDF2-HMAC-SHA256 600,000 iterations; bcrypt cost 10.
  • Store the algorithm, version, parameters, salt and hash together so old and new hashes can coexist.
  • Upgrade on successful login with a compare-and-swap write, and wrap legacy fast hashes now so inactive accounts are protected too.
  • NIST SP 800-63B-4: at least 15 characters for a password used alone, allow 64 or more, screen a blocklist, no composition rules or forced expiry.
  • Bound password length, stored parameters and hashing concurrency so verification cannot exhaust memory.

Verify it

  1. Read a newly stored hash: it starts with $argon2id$v=19$ and its m, t and p values meet the policy table.
  2. Log in to an account whose hash uses old parameters, then confirm the row now holds a hash with the current parameters.
  3. Submit a wrong password for that account and confirm the stored hash and its version are unchanged.
  4. Reset the password while a login upgrade is in flight and confirm the reset hash survives.
  5. Measure p95 hashing time and peak memory at the expected login concurrency on production hardware.
  6. Try a 64-character password and a blocklisted password: the first is accepted, the second is rejected with a reason.
Applies to
  • argon2-cffi (Python) 25.1
  • Spring Security 5.8 or later (tested with 7.1.1)
  • ASP.NET Core Identity .NET 7 or later (tested with 10.0)

Applications that verify passwords themselves. The parameters below are minimums; benchmark the deployed service before raising them.

Part of
Workflows: Fix a finding, Secure a feature

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 33 minutes: 11 to read and 22 for the exercise

What you will learn

  • Choose a password hashing algorithm and parameters from current minimums.
  • Explain salts, peppers, memory-hard work and the PHC string format.
  • Budget verification concurrency.
  • Implement upgrade-on-success without overwriting a reset.

Before you start

Developers who store or migrate password hashes, and reviewers who check the parameters and the upgrade path.

  • Distinguish a password verifier, salt, work factor and an encryption key.

Refresh first: Session management: cookies, timeouts, rotation and revocation

Go to the guided practice

A password verifier should make every offline guess expensive

After a password database is stolen, an attacker can test candidate passwords without contacting the login endpoint. Rate limiting the endpoint no longer constrains that work. A password hashing function deliberately consumes computation and, for memory-hard designs, memory. The objective is to increase the cost per offline guess while keeping legitimate authentication supportable.

Store a hash record containing the algorithm identifier, its version, cost parameters, a fresh random salt, and the resulting derived value. The salt is public and unique per record; it prevents the same password from producing the same stored value and defeats reuse of one precomputed table across all accounts. It does not make a weak password unpredictable.

A pepper is an additional secret kept outside the password database and applied as a keyed hash or encryption step, for example an HMAC over the password hash. NIST SP 800-63B-4 recommends one and asks for the key to be stored separately, ideally in a hardware security module. It reduces the value of a database-only theft, but adds a dependency on secret storage and a recovery and rotation problem: losing the pepper makes every associated hash unusable. A pepper is not a substitute for a password hashing function or a per-user salt.

Fast general-purpose hashes such as plain SHA-256 are inappropriate as the sole password-storage mechanism. Their speed is valuable for file integrity and harmful when an attacker can try billions of guesses. Encrypting passwords reversibly creates a key whose compromise reveals all passwords directly.

Which algorithm and parameters should you use?

Use Argon2id for new systems. Use scrypt where Argon2id is unavailable, PBKDF2 where a FIPS 140-validated module is required, and bcrypt only where it is already deployed. The table gives the OWASP Password Storage Cheat Sheet minimums as of October 2026; each algorithm has alternatives of equal strength, so pick the one that fits your memory budget.

Store every hash in the PHC string format, which records the algorithm, version, parameters, salt and hash, for example $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>. Libraries such as argon2-cffi, Spring Security and libsodium read the parameters back from that string, so hashes made under an old policy keep verifying while new ones use the new policy.

Which algorithm and parameters should you use?
AlgorithmMinimum parametersUse it whenWatch for
Argon2idm=19456 (19 MiB), t=2, p=1; or 46 MiB with t=1; or 12 MiB with t=3New systemsLanes (p) split the memory; they do not add work. RFC 9106 profiles: 2 GiB with t=1, or 64 MiB with t=3, both with p=4.
scryptN=2^17 (128 MiB), r=8, p=1; or 64 MiB with p=2Argon2id is unavailableMemory per hash is 128 × N × r bytes, so N and r set the memory cost together.
PBKDF2HMAC-SHA256: 600,000 iterations; HMAC-SHA512: 220,000FIPS 140 validation is requiredNot memory-hard, so GPUs attack it cheaply. ASP.NET Core Identity defaults to 100,000 iterations of HMAC-SHA512, below this minimum.
bcryptCost 10 or more (the cost is a power of two)Existing deployments onlyMost implementations use only the first 72 bytes and some reject longer input; cap the length or pre-hash with a peppered HMAC, base64-encoded.

Bcrypt or Argon2id: which should you use?

Use Argon2id for new systems. Keep bcrypt only where it is already deployed, at cost 10 or more, and move those hashes to Argon2id at each user's next successful login. Both are salted and tunable, and a bcrypt hash at a sensible cost is not an emergency; the difference is what an attacker with dedicated hardware pays per guess.

Argon2id is memory-hard: every guess must fill and read the amount of memory you choose (19 MiB at OWASP's minimum), which makes large-scale guessing on GPUs and custom hardware expensive. Bcrypt, designed in 1999, works in about 4 KB of state, so the same hardware runs many more guesses in parallel. Argon2 won the Password Hashing Competition in 2015 and is specified in RFC 9106 (2021); OWASP now recommends bcrypt only for legacy systems where Argon2id and scrypt are unavailable.

Bcrypt has two practical traps. Most implementations use only the first 72 bytes of the password and some reject longer input, so either cap length at 72 bytes or pre-hash with a keyed HMAC and base64-encode the result; a plain unkeyed pre-hash invites password shucking. And bcrypt is not FIPS-approved: where FIPS 140 validation is required, use PBKDF2-HMAC-SHA256 at 600,000 iterations instead of either.

Bcrypt or Argon2id: which should you use?
QuestionArgon2idbcrypt
Memory-hardYes, memory set by mNo, about 4 KB of state
Minimum parameters (OWASP)m=19456 (19 MiB), t=2, p=1Cost 10
Password lengthNo practical limit; cap it yourself, for example at 128 charactersFirst 72 bytes in most implementations
FIPS 140Not approvedNot approved (use PBKDF2)
StandardRFC 9106No IETF or NIST standard
VerdictDefault for new systemsKeep where deployed; migrate on login

Treat cost parameters as a capacity decision with security consequences

The minimums are floors, not targets. Raise memory first, then passes, until hashing uses the latency your login path can afford on production hardware; OWASP suggests keeping one hash under a second. RFC 9106’s second profile (64 MiB, 3 passes, 4 lanes) is the default of the argon2-cffi library and a sensible target where memory allows. Do not copy a timing result measured on a developer laptop into production capacity planning.

Benchmark the actual deployed implementation under expected concurrency. In a simple upper-bound estimate, 40 simultaneous hashes at 64 MiB each require 2.5 GiB for hashing memory alone. The process also needs normal application memory, queues, and headroom. Letting unauthenticated clients create unlimited concurrent expensive work can exhaust the server.

Bound the input length and hashing concurrency without silently truncating passwords: accept at least 64 characters and reject anything above your cap, such as 128 characters. Return consistent authentication failures and apply rate limits at appropriate account and source dimensions. Keep the memory and time budget explicit so future cost increases can be evaluated against both attacker economics and service reliability.

Store cost parameters in each record because a fleet rarely migrates all passwords at once. Strictly parse and bound those values before handing them to the library; a corrupted or attacker-modified database record requesting absurd memory must not turn verification into resource exhaustion.

What password rules does NIST SP 800-63B-4 require?

NIST SP 800-63B-4 (final, July 2025) replaced the 2017 password rules. OWASP ASVS 5.0 chapter V6.2 asks for the same checks, with an 8-character minimum and 15 characters strongly recommended.

  • Require at least 15 characters when the password is the only factor, and at least 8 when it is one factor of a multi-factor login.
  • Allow at least 64 characters, every printable ASCII character, spaces and Unicode. Count each Unicode code point as one character and normalize to NFC before hashing.
  • Check new passwords against a blocklist of breached, common and context-specific values such as the service name and the username, comparing the whole password.
  • Do not impose composition rules, periodic expiry, password hints or security questions. Force a change only on evidence of compromise.
  • Verify the entire password and never truncate it, so bcrypt’s 72-byte limit needs an explicit length check or a pre-hash.
  • Allow paste and password managers, and limit consecutive failed attempts per account to no more than 100.

Upgrade with the password you just verified

On login, parse the existing record, verify the submitted password with its recorded algorithm and parameters, and reject on failure. Only after success, decide whether the record needs an upgrade. Compute a fresh verifier from the submitted password with the new profile and salt, then replace the old record safely.

Do not wait for every user to log in; the LastPass case study at /domains/identity-crypto/secrets/case-study-lastpass-2022/ shows what happens when an upgrade never reaches old records. Protect legacy fast hashes now by wrapping them: if a table holds md5(password), store argon2id(md5(password)) with a version marker, and verify by computing the inner hash first. OWASP describes this layering as the way to protect inactive accounts at once. Treat wrapped records as transitional, because the result is weaker than a direct hash when the inner hash has also leaked elsewhere (password shucking). Replace each one with a direct hash at that user’s next login, and require a reset for accounts still wrapped after a set date.

Concurrent logins and password changes create a race. Use a record version or compare-and-swap so an upgrade based on an older successful login cannot overwrite a more recent password reset. A failed password attempt must never trigger a verifier replacement.

When a credential is believed compromised, changing the storage algorithm does not make that password secret again. Require an appropriate reset, revoke relevant sessions, and preserve incident evidence without copying raw submitted passwords into logs.

Rehash on login in Python with argon2-cffi

This login function verifies against whatever parameters the stored hash records, refuses stored hashes that ask for unreasonable memory, spends the same work on unknown users, and moves a hash whose parameters differ from the policy to the current policy with a compare-and-swap write. It was tested with argon2-cffi 25.1 on Python 3.14, including the concurrent-reset case.

check_needs_rehash returns true whenever the stored parameters differ from the hasher's, including stronger ones, so a hash made with the RFC 9106 64 MiB profile would be rewritten to the 19 MiB policy above. Raise the policy rather than keep mixed parameters, or compare the extracted parameters yourself if you want to keep stronger hashes. Node's argon2 needsRehash behaves the same way.

Fixedpasswords.pyPython
import sqlite3

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

# OWASP minimum Argon2id profile: 19 MiB, 2 passes, 1 lane. Raise it after benchmarking.
ph = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
MAX_CHARS = 128  # bounds the work; NIST SP 800-63B-4 asks you to allow at least 64
MAX_MEMORY_KIB = 262144  # refuse stored hashes that ask for more than 256 MiB
DUMMY_HASH = ph.hash("timing-equaliser")


def hash_password(password: str) -> str:
    if len(password) > MAX_CHARS:
        raise ValueError("password too long")
    return ph.hash(password)  # $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>


def login(db: sqlite3.Connection, user_id: int, password: str) -> bool:
    row = db.execute("SELECT pw_hash, version FROM users WHERE id = ?", (user_id,)).fetchone()
    stored, version = row if row else (DUMMY_HASH, None)
    try:
        if len(password) > MAX_CHARS or extract_parameters(stored).memory_cost > MAX_MEMORY_KIB:
            return False
        ph.verify(stored, password)  # raises on a wrong password or a malformed hash
    except (VerificationError, InvalidHashError):
        return False
    if version is None:
        return False  # unknown user: same work, same answer
    if ph.check_needs_rehash(stored):  # parameters differ from the current policy
        # Compare-and-swap: a reset that ran meanwhile changed the version, so skip.
        db.execute(
            "UPDATE users SET pw_hash = ?, version = version + 1 WHERE id = ? AND version = ?",
            (ph.hash(password), user_id, version),
        )
        db.commit()
    return True

Rehash on login in Node.js with argon2

The same login in Node.js with the argon2 package, which wraps the reference C implementation. The update uses the verified hash as its compare-and-swap value, so a password reset that lands between verification and the write matches no row. Tested with argon2 0.45.1 and the built-in node:sqlite on Node.js 26.3: new hashes carry the policy, a wrong password changes nothing, an outdated hash is upgraded, and a reset during the upgrade survives. This package writes the parameters in the order m, p, t; other libraries read either order. The Node.js password-hashing fix card at /fix/password-hashing/node-express/ runs the same pattern inside an Express app.

Fixedpasswords.mjsJavaScript
import argon2 from 'argon2';
import {DatabaseSync} from 'node:sqlite';

// OWASP minimum Argon2id profile; every hash records its own parameters, so old ones still verify.
const POLICY = {type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1};
const MAX_CHARS = 128; // bounds the work; NIST SP 800-63B-4 asks you to allow at least 64
const DUMMY_HASH = await argon2.hash('timing-equaliser', POLICY);

export const hashPassword = password => argon2.hash(password, POLICY); // $argon2id$v=19$m=19456,p=1,t=2$<salt>$<hash>

export async function login(db, username, password) {
  if (password.length > MAX_CHARS) return false;
  const row = db.prepare('SELECT id, pw_hash FROM users WHERE username = ?').get(username);
  const stored = row?.pw_hash ?? DUMMY_HASH; // unknown user: same work, same answer
  if (!(await argon2.verify(stored, password)) || !row) return false;
  // needsRehash compares memory, passes, lanes and version with POLICY, not the variant.
  if (!stored.startsWith('$argon2id$') || argon2.needsRehash(stored, POLICY)) {
    // Compare-and-swap on the verified hash: a reset that landed meanwhile matches no row.
    db.prepare('UPDATE users SET pw_hash = ? WHERE id = ? AND pw_hash = ?')
      .run(await hashPassword(password), row.id, stored);
  }
  return true;
}

Upgrade hashes with Spring Security

DelegatingPasswordEncoder prefixes each hash with its encoder ID, verifies old {bcrypt} hashes, and reports through upgradeEncoding when a hash uses another encoder or weaker Argon2 parameters. With DaoAuthenticationProvider and a UserDetailsPasswordService bean, Spring Security performs this upgrade after a successful login for you. Argon2 support needs Bouncy Castle on the classpath.

FixedPasswords.javaJava
import java.util.Map;
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;

public final class Passwords {
  // New hashes: {argon2}$argon2id$v=19$m=19456,t=2,p=1$... (16-byte salt, 32-byte hash).
  // Older {bcrypt} hashes still verify and are reported as needing an upgrade.
  public static final PasswordEncoder ENCODER = new DelegatingPasswordEncoder("argon2", Map.of(
      "argon2", new Argon2PasswordEncoder(16, 32, 1, 19456, 2), // salt, hash, p, m KiB, t
      "bcrypt", new BCryptPasswordEncoder(12)));

  /** After a successful login, the hash to store instead (with a version check), or null. */
  public static String upgradeAfterLogin(String password, String stored) {
    if (!ENCODER.matches(password, stored)) {
      throw new IllegalArgumentException("bad credentials"); // never rehash on failure
    }
    return ENCODER.upgradeEncoding(stored) ? ENCODER.encode(password) : null;
  }
}

Raise the ASP.NET Core Identity work factor

ASP.NET Core Identity stores PBKDF2-HMAC-SHA512 hashes with the iteration count inside each hash. Raising IterationCount makes VerifyHashedPassword return SuccessRehashNeeded for older hashes, and UserManager.CheckPasswordAsync stores the new hash after a correct password. Tested on .NET 10 with Microsoft.Extensions.Identity.Core 10.0.12.

ConfigurationProgram.csC#
// Program.cs. Identity hashes with PBKDF2-HMAC-SHA512 at 100,000 iterations by default.
// OWASP's minimum for that PRF is 220,000. UserManager.CheckPasswordAsync then reports
// SuccessRehashNeeded for older hashes and stores a new hash after the correct password.
builder.Services.Configure<PasswordHasherOptions>(o => o.IterationCount = 220_000);

Verify compatibility, migration, and concurrency separately

Ordinary verification of an old record should succeed for the correct password, then produce a new record that verifies the same password. A wrong password must fail and leave the record unchanged. Two identical passwords should use different salts and yield different complete verifier records.

Boundary tests include a 64-character password, the maximum accepted length, a longer input, a blocklisted password, unsupported algorithm versions, unreasonable stored cost values, and a simultaneous reset. Test error behavior as well as acceptance. These checks complement library cryptographic tests; hand-written code should orchestrate a maintained password API rather than implement Argon2.

Guided practice

You inherit a user table whose pw_hash column holds the six values below, from three generations of code. The current policy is Argon2id m=19456,t=2,p=1, with stored parameters refused above 256 MiB. Read each value, decide what it is and what happens at the user's next login, and write the migration. Work it out before reading the answers.

Supplied scenario material
Plain text
H1 $argon2id$v=19$m=19456,t=2,p=1$mU2/hsZi6QuNqOXqJEARcw$tdqBDg1uOjHICh1Dsoq79H7rBQsQDdvkAdwwFzwInls
H2 $argon2id$v=19$m=65536,t=3,p=4$H0q/PUPq36+WleqezH34LQ$DWMNZbSDr7Qgx6UbUZSG3dNc3sZc+on5XrixNESFqxY
H3 $argon2i$v=19$m=4096,t=3,p=1$dCZX9/YH0UG5pbThKMF+Xw$VY4WSJKhQ4P+U/udBMkWPXy4wlr/tWSXJms6WJB+vOE
H4 $2b$10$FhGw5etC4/u9jV9bhMbd9e5I2kZN8t35q2D1XkZgqwWD/LgdcJDIe
H5 5f4dcc3b5aa765d61d8327deb882cf99
H6 $argon2id$v=19$m=4194304,t=1,p=4$mU2/hsZi6QuNqOXqJEARcw$tdqBDg1uOjHICh1Dsoq79H7rBQsQDdvkAdwwFzwInls
Login service: 40 concurrent hashes at most. H5 accounts include 30,000 users who have not logged in for two years.
  1. 1. Identify each format

    Name the algorithm, variant and parameters of H1 to H6, and the memory each needs per hash.

    Shell
    pip install argon2-cffi
    # single quotes stop the shell from expanding $argon2i
    python3 -c 'from argon2 import extract_parameters as p; print(p("$argon2i$v=19$m=4096,t=3,p=1$dCZX9/YH0UG5pbThKMF+Xw$VY4WSJKhQ4P+U/udBMkWPXy4wlr/tWSXJms6WJB+vOE"))'
    Expected result and reasoning

    H1: Argon2id at the policy, 19 MiB. H2: Argon2id, 64 MiB, 3 passes, 4 lanes (RFC 9106's second profile), stronger than the policy. H3: Argon2i, the side-channel-resistant variant, at only 4 MiB. H4: bcrypt, $2b$, cost 10 (2^10 rounds). H5: 32 hex digits with no salt or parameters, an unsalted MD5 (it is the MD5 of the word password). H6: Argon2id claiming m=4194304 KiB, which is 4 GiB per verification.

  2. 2. Decide the action at next login

    For each hash, say whether login should verify, rehash, refuse or reset, using the Python or Node.js code on this page.

    Expected result and reasoning

    H1: verify, nothing to do. H2: verify; check_needs_rehash returns true because the parameters differ, so the code would rewrite it to the weaker 19 MiB policy; either raise the policy to match or skip downgrades. H3 and H4: verify with their own algorithm, then rehash to Argon2id. H5: cannot be verified by the Argon2 path at all; it needs a legacy branch or, better, wrapping now. H6: refuse before hashing, because 4 GiB exceeds the 256 MiB ceiling; a stored record that asks for that much memory is corrupt or tampered with, so route the account to recovery.

  3. 3. Protect the dormant MD5 accounts now

    Write the one-off migration for the 30,000 H5 accounts that may never log in again.

    Expected result and reasoning

    Replace each value with argon2id(md5_hex) and a marker such as legacy-md5-wrapped, so every stored value is slow to attack today. At login, compute the MD5 of the submitted password, verify it against the wrapped hash, then store a direct Argon2id hash of the password. After a deadline, force a reset for accounts still wrapped, because a wrapped MD5 is weaker than a direct hash if the old MD5 values leaked elsewhere.

  4. 4. Check the capacity

    Compute the hashing memory at 40 concurrent logins for the policy and for H2's profile.

    Expected result and reasoning

    40 x 19 MiB = 760 MiB at the policy; 40 x 64 MiB = 2,560 MiB, 2.5 GiB, if you move everyone to H2's profile. Benchmark before raising the policy and bound concurrency with a queue.

  5. 5. Write the regression tests

    List the tests that prove the migration works.

    Expected result and reasoning

    A correct password upgrades H3, H4 and wrapped H5 to $argon2id$v=19$m=19456,t=2,p=1$; a wrong password changes nothing; H6 is refused without allocating memory; a reset that lands during an upgrade survives (compare-and-swap); and a 73-byte password is rejected or handled explicitly on the bcrypt path rather than truncated silently.

Your deliverable: A table of H1 to H6 with format, memory and action, the wrap-now migration for H5, and a list of five regression tests.

The hashes were generated with argon2-cffi 25.1 and bcrypt for this exercise; H6 is H1 with its memory parameter edited, so it does not verify. No passwords are cracked or attacked here.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Repair the code

Password hashing lab: Verify and upgrade an Argon2 password hash on login

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. OWASP Password Storage Cheat Sheet
  2. RFC 9106: Argon2 memory-hard function. September 2021.
  3. NIST SP 800-63B-4: Authentication and authenticator management. SP 800-63B-4 (July 2025).
  4. PHC string format
  5. argon2-cffi: PasswordHasher API. argon2-cffi 25.1.0.
  6. Spring Security: password storage

Reference information

SDLC phase
Development
ASVS 5.0.0 requirements
v5.0.0-6.2.1 · v5.0.0-6.2.8 · v5.0.0-6.2.9 · v5.0.0-6.2.12 · v5.0.0-11.4.2

Download this guide as Markdown