Skip to content
MINH VO A working notebook
by an engineer in Vietnam
Foundation11 min read

Hash passwords with a work factor you can maintain

Inspect actual Argon2id records, measure three explicit policies and use a conditional migration write to preserve a password reset that committed first.

An olive paper gate and dark connecting paths leading to a small storage cabinet on a cream background

A password hash is part of a long-lived data format. The login code you deploy today may need to verify records created years earlier, change their parameters, and coexist with password resets happening in another request. Choosing an expensive algorithm is only one part of making that arrangement work.

This lesson assumes basic Python and SQL. We will use a maintained Argon2 library, inspect real encoded records, measure three explicit configurations, and reproduce a stale-write bug during a password reset. The examples verify and migrate stored hashes. They do not implement a complete login endpoint, issue sessions, or establish a password policy for enrollment.

Read the record you are storing

A password verifier needs to check a supplied password without storing a recoverable copy. A fast general-purpose digest such as SHA-256 is the wrong primitive for ordinary password storage: guessing candidates is deliberately cheap. OWASP’s password-storage guidance recommends purpose-built password hashing and gives Argon2id configurations with explicit memory, iteration and parallelism settings.

A salt is random input generated for an individual hash. It is stored with the result; it is not a second password or a secret encryption key. For records with distinct salts, an attacker must evaluate a candidate separately for each salted record. They do not make a weak password difficult to guess by itself. Let the high-level library generate salts rather than deriving them from usernames or supplying a fixed fixture salt to production code.

An actual Argon2id record is separated into algorithm, version, memory and iteration parameters, a random salt and the derived tag.View full-size image ↗

This record came from the public synthetic password in the runnable example. Its salt and tag are actual output, not values invented for the diagram.

The encoded string carries the algorithm, version and parameters needed for verification. In m=65536,t=3,p=1, memory is measured in KiB, so 65536 means 64 MiB. The iteration count and parallelism have their own meanings; this is not a single universal “cost” number. Preserve the entire encoded value in storage. Splitting it into columns is unnecessary for this example, and truncating it breaks verification.

Use explicit policies and the library’s verifier

Create a private working directory and install the recorded versions. The binding version is pinned as well because it contains the native implementation used by this execution.

python3 -m venv .venv
. .venv/bin/activate
pip install argon2-cffi==25.1.0 argon2-cffi-bindings==26.1.0

Save this as hashing.py. The older configuration uses 19456 KiB, two iterations and one lane, matching the 19 MiB Argon2id option listed by OWASP when this lesson was checked. The selected target uses 65536 KiB, three iterations and one lane. It is an experiment configuration, not a universal deployment recommendation or the RFC’s four-lane low-memory profile.

from dataclasses import dataclass
from argon2 import PasswordHasher, Parameters, Type, extract_parameters
from argon2.exceptions import VerifyMismatchError

# Explicit experiment policies, not universal deployment recommendations.
LEGACY = PasswordHasher(memory_cost=19456, time_cost=2, parallelism=1,
                        salt_len=16, hash_len=32, type=Type.ID)
CURRENT = PasswordHasher(memory_cost=65536, time_cost=3, parallelism=1,
                         salt_len=16, hash_len=32, type=Type.ID)
APPROVED_OLD = Parameters(type=Type.ID, version=19, salt_len=16, hash_len=32,
                          time_cost=2, memory_cost=19456, parallelism=1)

@dataclass(frozen=True)
class CheckedPassword:
    outcome: str
    replacement: str | None = None


def verify_and_prepare(encoded, password):
    # encoded comes from our account store, never from the login request.
    try:
        CURRENT.verify(encoded, password)
    except VerifyMismatchError:
        return CheckedPassword('mismatch')
    # Invalid stored hashes and other operational failures propagate.
    if not CURRENT.check_needs_rehash(encoded):
        return CheckedPassword('current')
    # A parameter difference is not an ordering of cryptographic strength.
    if extract_parameters(encoded) != APPROVED_OLD:
        return CheckedPassword('policy-review')
    return CheckedPassword('rehash-ready', CURRENT.hash(password))


def replace_if_unchanged(connection, account_id, observed, replacement):
    changed = connection.execute(
        'UPDATE accounts SET password_hash = ? '
        'WHERE id = ? AND password_hash = ?',
        (replacement, account_id, observed),
    ).rowcount
    connection.commit()
    return changed == 1


if __name__ == '__main__':
    # Public, synthetic fixture values. Never print real login passwords.
    password = 'only a demonstration password'
    first = CURRENT.hash(password)
    second = CURRENT.hash(password)
    print('first record:', first)
    print('second record:', second)
    print('same password, different records:', first != second)
    print('current match:', verify_and_prepare(first, password).outcome)
    print('wrong password:', verify_and_prepare(first, 'wrong').outcome)
    old = LEGACY.hash(password)
    prepared = verify_and_prepare(old, password)
    print('old policy:', prepared.outcome)
    print('replacement verifies:', CURRENT.verify(prepared.replacement, password))
    print('replacement needs this policy:', CURRENT.check_needs_rehash(prepared.replacement))
    print('old hasher also flags the new record:', LEGACY.check_needs_rehash(first))

Run python hashing.py. Two hashes of the same fixture password have different salts and records, and both can be verified with the same password. Verification uses the parameters encoded in each stored record. Constructing CURRENT does not force an old record to be verified with the new memory setting.

The library API returns true for a successful verification and raises exceptions otherwise. The function catches only a password mismatch. A malformed stored record or a failure inside the hashing implementation remains an operational error. A real HTTP boundary needs to handle those errors without exposing stack traces or internal classifications to the caller; treating every storage failure as “wrong password” would hide a different problem.

The verifier assumes the application controls the encoded hash. Load it from your account store. Do not let a login request choose an arbitrary encoded record and its resource parameters. This example passes a string password through the library’s UTF-8 encoding; it does not trim, truncate or normalize that string. Any enrollment and login normalization policy must agree, rather than changing silently during migration.

A different parameter set is not automatically an upgrade

check_needs_rehash asks whether a record matches this hasher’s parameters. It does not rank two records by security. The demonstration’s older hasher also reports that a newer record needs rehashing. Blindly interpreting that answer as “make this stronger” can move a record in the wrong direction during a rollback or mixed-version deployment.

The code therefore approves one known source parameter set for automatic migration. A successfully verified record with another parameter set returns policy-review without a replacement. That includes a record with more iterations than the selected target. The operator must decide how to handle it. This is a small allowlist for the experiment, not a universal algorithm for comparing password-hash strength.

Actual Python output distinguishes a current match, a mismatch, an approved rehash and the older hasher also flagging the new record.View full-size image ↗

The output exposes the direction problem in the parameter comparison. A true rehash flag alone does not authorize a migration.

Rehash after successful verification because that is when the supplied password is available. Hashing the old encoded string would protect that string as a new password; it would not upgrade the original password’s verifier. The argon2-cffi walkthrough describes login-time rehashing. Django’s password-upgrading documentation gives a useful framework example and explicitly notes that parameter changes can increase or decrease settings.

An account whose owner never logs in again never reaches this path. A migration plan therefore needs a separate decision for dormant records, such as a reset flow. Retain the verification support needed by existing records until that transition is handled. Removing an old algorithm from the application is not the same as migrating every stored hash.

Measure a configuration in the environment that must support it

The Argon2 parameter guidance and RFC 9106 discuss memory, lanes and affordable time together. Their example profiles do not tell you how many simultaneous login attempts your deployment can sustain.

Save the following as measure.py. It performs one excluded warm-up and seven sequential hashes for each configuration. Each hash gets a fresh library-generated salt. The timed interval includes the high-level hashing call, not an isolated measurement of a cryptographic inner loop.

from time import perf_counter_ns
from statistics import median
from importlib.metadata import version
import json, platform
from argon2 import PasswordHasher, Type

rows = []
for memory, iterations in [(19456, 2), (32768, 2), (65536, 3)]:
    hasher = PasswordHasher(memory_cost=memory, time_cost=iterations,
                            parallelism=1, salt_len=16, hash_len=32, type=Type.ID)
    hasher.hash('synthetic timing input')  # One warm-up, excluded.
    samples = []
    for _ in range(7):
        start = perf_counter_ns()
        hasher.hash('synthetic timing input')
        samples.append((perf_counter_ns() - start) / 1_000_000)
    rows.append({'memoryKiB': memory, 'iterations': iterations, 'parallelism': 1,
                 'milliseconds': samples, 'medianMilliseconds': median(samples)})
print(json.dumps({'python': platform.python_version(), 'machine': platform.machine(),
                  'library': version('argon2-cffi'),
                  'bindings': version('argon2-cffi-bindings'), 'rows': rows,
                  'scope': 'One process, sequential calls, seven samples per policy. '
                           'No concurrent login traffic, attacker benchmark or capacity estimate.'}, indent=2))

A chart of seven actual local hashing times per configuration shows individual observations and their medians, with memory, iterations and parallelism labeled.View full-size image ↗

One Python 3.12.3 process on Linux x86-64, argon2-cffi 25.1.0 and bindings 26.1.0. Other activity on the machine was uncontrolled. These are local observations, not attacker benchmarks or a login-capacity estimate.

The target configuration’s median was about 375 milliseconds in this run, with observations from roughly 227 to 491 milliseconds. That spread is a reason to retain samples and repeat measurements under the intended deployment conditions. It is not a reason to present the fastest sample as a service promise. Verification, a successful migration and an ordinary current-policy login also perform different amounts of work: migration may require both verification and a new hash.

Memory is a concurrency concern as well as a per-call setting. Several simultaneous 64 MiB computations can exceed a small container’s budget before accounting for the application, allocator and other requests. Configured Argon2 memory is not a measured process-RSS ceiling. Bound expensive work with an appropriate queue, concurrency limit and request-abuse controls, then test those controls with the actual service. None of that traffic or memory-pressure behavior is exercised by this sequential script.

Do not overwrite a password reset with a stale rehash

The replacement was prepared from a particular stored record. Another request may change that record before the replacement is written. An unconditional update can restore the old password after a reset has already committed.

replace_if_unchanged updates only the account whose hash still equals the observed value. It owns the commit on its dedicated SQLite connection; do not pass a connection carrying unrelated pending work. Save this separate exercise as race.py and run it after hashing.py is available:

from pathlib import Path
from tempfile import TemporaryDirectory
import sqlite3
from hashing import CURRENT, LEGACY, verify_and_prepare, replace_if_unchanged

old_password = 'old synthetic password'
new_password = 'new synthetic password'
with TemporaryDirectory() as directory:
    database = Path(directory) / 'accounts.sqlite'
    login = sqlite3.connect(database)
    reset = sqlite3.connect(database)
    try:
        login.execute('CREATE TABLE accounts (id INTEGER PRIMARY KEY, password_hash TEXT NOT NULL)')
        for guarded in (False, True):
            original = LEGACY.hash(old_password)
            login.execute('DELETE FROM accounts')
            login.execute('INSERT INTO accounts VALUES (?, ?)', (7, original))
            login.commit()
            observed = login.execute('SELECT password_hash FROM accounts WHERE id = 7').fetchone()[0]
            prepared = verify_and_prepare(observed, old_password)
            assert prepared.outcome == 'rehash-ready'

            # Another connection commits a password reset after our read.
            reset_record = CURRENT.hash(new_password)
            reset.execute('UPDATE accounts SET password_hash = ? WHERE id = 7', (reset_record,))
            reset.commit()
            if guarded:
                changed = replace_if_unchanged(login, 7, observed, prepared.replacement)
            else:
                changed = login.execute('UPDATE accounts SET password_hash = ? WHERE id = 7',
                                        (prepared.replacement,)).rowcount == 1
                login.commit()
            stored = reset.execute('SELECT password_hash FROM accounts WHERE id = 7').fetchone()[0]
            old_matches = verify_and_prepare(stored, old_password).outcome == 'current'
            new_matches = verify_and_prepare(stored, new_password).outcome == 'current'
            print('guarded:', guarded, 'changed:', changed,
                  'old matches:', old_matches, 'new matches:', new_matches)
            assert (changed, old_matches, new_matches) == ((False, False, True) if guarded else (True, True, False))
    finally:
        reset.close()
        login.close()

The two real SQLite connections interleave a read, a committed reset and the delayed write. This is an explicit ordering, not a load test or an observation of a lock wait. The unsafe branch restores the old password. The conditional branch changes zero rows and preserves the reset password.

Actual two-connection results contrast an unconditional write that restores the old password with a conditional write that preserves the committed reset.View full-size image ↗

A failed conditional write means the observed record is no longer current. It does not identify which competing operation changed it.

Another successful rehash can also cause the conditional write to affect zero rows. Reload and follow the application’s credential-consistency policy; do not blindly retry the stale replacement or label every conflict a bad password. The helper protects the stored value from this stale overwrite. It does not make password verification, session issuance and reset-driven session revocation one atomic operation. That requires a separate design, often involving a credential generation checked by the session policy.

Keep the result categories useful

The following outcomes came from the executed helper and storage controls. current and policy-review mean that this supplied password matched this observed record. They do not say the account is enabled, a second factor passed, or a session should now be issued.

An outcome table distinguishes mismatch, current policy, approved migration, unknown policy, malformed record and a concurrent storage change.View full-size image ↗

Changing hash policies can also change response timing. Django’s documentation warns that mixed algorithms and nonexistent-account handling can expose timing differences. This small helper does not solve account enumeration or rate limiting. Use it to inspect the storage and migration boundary, then evaluate the surrounding authentication flow separately.

Before rolling out a parameter change, list the policies already stored, the ones each deployed application version can verify, and the exact transitions you approve. Measure the target on the deployment, exercise reset and concurrent-migration conflicts, and decide what happens to accounts that never return. A maintainable work factor is a policy you can operate and migrate without losing the account state it is meant to protect.

Sources & further reading

  1. OWASP: Password Storage Cheat Sheet
  2. argon2-cffi: How to hash a password
  3. argon2-cffi: API reference
  4. argon2-cffi: Choosing parameters
  5. RFC 9106: Argon2 memory-hard function
  6. Django 5.2: Password management
← Back to the journal
All notes

Illustration

100%