Application security engineer testing Argon2 password rehash code in Python to prevent a stale database update from overwriting a newer password reset

Argon2 Rehashing: Prevent Stale Updates From Undoing a Password Reset

Wed, Oct 28, 2026

A password matches an existing Argon2 verifier. The application decides that verifier is eligible for an approved parameter rehash and generates a replacement. Before that replacement reaches storage, however, the credential owner resets the password. What should happen when the delayed rehash finally attempts its write?

An approved rehash can be stored without replacing a newer password reset, but only if persistence is conditioned on the exact verifier that was successfully checked. A successful password match is evidence about that earlier verifier. It is not proof that the same verifier remains current when the database changes.

That distinction is the focus here. The experiment uses one local Python process, public synthetic fixture bytes, argon2-cffi 25.1.0, argon2-cffi-bindings 25.1.0, and an owned temporary SQLite file. “Concurrent password rehash handling” is represented by deliberately scheduled sequential interleavings. The experiment does not use simultaneous threads, requests, or servers.

The evidence chain is deliberately longer than “verification succeeded”: password match, parameter-policy decision, generated candidate, attempted conditional update, transaction outcome, fresh-connection readback, and the verifier actually retained. The supplied reference run on October 28, 2026 reports all twelve declared cases passing. A passing laboratory case, however, is not always an acceptable credential state: one case intentionally and successfully reproduces the unsafe overwrite this design is meant to reject.

Define the credential replacement contract

The starting population already contains Argon2id verifiers. There is no bcrypt-to-Argon2 migration, no enrollment workflow, no complete login endpoint, and no attempt to determine a universal production hashing profile. The specific contract is narrower: permit one explicitly authorized transition from the complete laboratory t=2 profile to the complete t=3 profile while preserving a different verifier that may have become current after verification.

That is a different question from Refonte Learning's bcrypt upgrade compatibility analysis, which examines candidate-byte acceptance across bcrypt versions. Here, password bytes and Argon2 package versions stay fixed while policy approval and persistence are tested.

Treat the workflow as distinct evidence stages:

snapshot → password match → policy decision → candidate → attempted update → commit/rollback → fresh readback → retained credential

Those stages produce the evidence categories NO_MATCH, VERIFIER_ERROR, NO_REHASH_NEEDED, HOLD_POLICY_DIFFERENCE, and PREPARED_NOT_STORED. Persistence evidence can then justify the operational disposition ACCEPT_REHASH_COMMITTED, HOLD_CHANGED_CREDENTIAL, or, for the deliberately unsafe negative control, RECONCILE_UNSAFE_OVERWRITE. The final three are not return values invented for prepare().

An unchanged-password rehash and a password reset are also different application events. The former encodes the same password under an approved target profile; the latter establishes a different current password.

This matters beyond the laboratory. OWASP describes opportunistically increasing password-hashing work factors after successful authentication, while also noting that hashes belonging to users who never return may never be upgraded. A login-triggered Argon2 parameter migration therefore cannot, by itself, prove fleet-wide completion.

Read the encoded policy before authorizing a change

The laboratory independently declares complete profiles instead of deciding from time_cost alone:

Profile

Type

Version

Salt

Hash

memory_cost

Parallelism

time_cost

Lab policy

Source

Argon2id

19

16 bytes

32 bytes

65,536 KiB

1

2

Eligible source

Target

Argon2id

19

16 bytes

32 bytes

65,536 KiB

1

3

Approved destination

Non-allowlisted

Argon2id

19

16 bytes

32 bytes

65,536 KiB

1

4

Hold

argon2-cffi documents memory_cost in KiB and exposes the encoded verifier's type, algorithm version, salt length, hash length, time cost, memory cost, and parallelism through extract_parameters(). Parameter selection is deployment-dependent and should be measured for the intended environment; the package's parameter guidance does not make this fixture a universal production recommendation.

The authorization rule is therefore not “rewrite anything different to t=3.” It is: after a successful password verification, replace only a verifier whose entire decoded profile equals the declared source profile. The t=4 fixture is intentionally outside that allowlist. This experiment does not classify it as stronger, weaker, faster, or slower than the target.

The fixed-version how-to shows the expected library sequence: verify, ask whether a rehash is needed, and then hash the cleartext password again. Its illustrative database setter is not a concurrent-write policy.

Interpret check_needs_rehash as parameter inequality

For this pinned version, treat check_needs_rehash() as a difference detector, not a policy-ranking function. The 25.1.0 source implementation returns the inequality between the configured PasswordHasher parameters and extract_parameters(hash). The tagged implementation confirms that comparison directly.

The API page's short return-description wording is imprecise about polarity, but its own matching-parameter example returns False; there is no need to turn that wording into a library-defect claim.

That distinction explains C04 and C05. C04 has the target parameters and returns needs_rehash=False. C05 is valid, matches the known password, and returns needs_rehash=True because its parameters differ. The application nevertheless returns HOLD_POLICY_DIFFERENCE: inequality is evidence for review, not authorization to rewrite.

Establish an owned credential baseline

The fixture uses only three public synthetic byte strings:

PASSWORD = b"owned-lab-password-v1"
RESET_PASSWORD = b"owned-lab-password-v2"
WRONG = b"owned-lab-wrong-candidate"

Every database row is created inside a temporary directory owned by the test. The experiment assumes the reader already understands the surrounding backend authentication foundations; it deliberately does not rebuild registration, login, JWT issuance, authorization, or account-management endpoints.

The profiles are declared independently before the cases run. old must decode as the complete t=2 source profile, current and reset as the t=3 target profile, and other_policy as the t=4 non-allowlisted profile. This prevents the test from declaring success merely because whatever the library happened to emit later matched itself.

PasswordHasher.hash() creates a fresh random salt when no salt is supplied, so encoded verifier strings are expected to differ between runs; two prepared rehash candidates from the same password can differ within one run. The stable oracles are decoded profile fields, known synthetic password relationships, equality against values captured within that run, expected update rowcounts, and post-transaction readback.

C08 intentionally revisits the row upgraded in C07. It proves that the retained t=3 verifier no longer asks this configured hasher for another replacement.

Separate a password match from the current stored state

prepare(stored, password) operates on a verifier value already read from storage. If TARGET.verify(stored, password) succeeds, argon2-cffi establishes a match for that encoded verifier; the API returns True on success and distinguishes a password mismatch from other verification failures through exceptions.

That result says nothing about whether another application event changes the stored verifier afterward. It also does not establish account status, session eligibility, reset-token validity, or authorization.

Accordingly, this laboratory treats a read through a newly opened SQLite connection after commit or rollback as the local retained-state observation. It is not a claim about external database systems, replication, or crash recovery.

Run the complete twelve-case laboratory

The package boundary is deliberately historical and pinned. PyPI records argon2-cffi 25.1.0 as released on June 3, 2025; this is qualification of a selected 2025 release in the October 28, 2026 reference run, not presentation of a new 2026 capability. The binding dependency is likewise pinned to argon2-cffi-bindings 25.1.0, whose project page describes it as the low-level CFFI binding package; the fixture itself uses the public high-level argon2-cffi API rather than that package's private interface.

The supplied reference run ran from 2026-10-28 07:17:25 through 07:17:28 UTC and returned PASS_12_CASES. Its recorded environment was Python 3.12.14, Linux 6.18.55 x86_64 with glibc 2.39, argon2-cffi 25.1.0, argon2-cffi-bindings 25.1.0, cffi 2.1.1, and linked SQLite 3.53.1. Those values describe the measured run; they are not minimum-version or security-profile recommendations.

Create an isolated Python 3.12 environment, save the fixture as argon2_rehash_fixture.py using UTF-8, LF line endings, and a final newline, then run:

python3.12 -m venv .venv
.venv/bin/python -m pip install argon2-cffi==25.1.0 argon2-cffi-bindings==25.1.0 cffi==2.1.1
.venv/bin/python argon2_rehash_fixture.py > argon2_rehash_results.json


The source SHA-256 recorded for the supplied reference run is:

7adb0727fe044b9b28b8bac4bdeb25faa6e680d119e3a5c77950f59f25e4c872

For a new run, approve and record the checksum of the exact fixture file you will execute; formatting changes alter source checksums.

The source is an owned local experiment and contains C09 as an intentionally unsafe negative control:

"""Synthetic Argon2 rehash/storage audit; no real credentials or login service."""
from contextlib import closing
from datetime import datetime, timezone
import hashlib
import importlib.metadata as metadata
import json
from pathlib import Path
import platform
import sqlite3
import sys
import tempfile

from argon2 import PasswordHasher, extract_parameters
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
from argon2.low_level import Type

PASSWORD = b"owned-lab-password-v1"
RESET_PASSWORD = b"owned-lab-password-v2"
WRONG = b"owned-lab-wrong-candidate"
HASH_CALLS = 0


def require(condition, message):
    if not condition:
        raise AssertionError(message)


def expected_profile(iterations):
    return {"type": "ID", "version": 19, "salt_len": 16, "hash_len": 32,
            "time_cost": iterations, "memory_cost": 65536, "parallelism": 1}


def profile(encoded):
    p = extract_parameters(encoded)
    return {"type": p.type.name, "version": p.version, "salt_len": p.salt_len,
            "hash_len": p.hash_len, "time_cost": p.time_cost,
            "memory_cost": p.memory_cost, "parallelism": p.parallelism}


def hasher(iterations):
    return PasswordHasher(time_cost=iterations, memory_cost=65536, parallelism=1,
                          hash_len=32, salt_len=16, type=Type.ID)


TARGET = hasher(3)


def generate(ph, password):
    global HASH_CALLS
    HASH_CALLS += 1
    return ph.hash(password)  # Let the library generate a fresh random salt.


def matches(encoded, password):
    try:
        return TARGET.verify(encoded, password)
    except VerifyMismatchError:
        return False


def prepare(stored, password):
    try:
        TARGET.verify(stored, password)
    except VerifyMismatchError:
        return "NO_MATCH", None
    except (InvalidHashError, VerificationError):
        return "VERIFIER_ERROR", None
    if not TARGET.check_needs_rehash(stored):
        return "NO_REHASH_NEEDED", None
    # Approved lab transition only; parameter inequality is not a strength order.
    if profile(stored) != expected_profile(2):
        return "HOLD_POLICY_DIFFERENCE", None
    return "PREPARED_NOT_STORED", generate(TARGET, password)


def write_sql(path, sql, args=(), commit=True):
    with closing(sqlite3.connect(path, autocommit=False)) as db:
        rows = db.execute(sql, args).rowcount
        db.commit() if commit else db.rollback()
        return rows


def read_hash(path, account):
    with closing(sqlite3.connect(path, autocommit=False)) as db:
        row = db.execute("SELECT verifier FROM credential WHERE account=?", (account,)).fetchone()
        require(row is not None, "Missing owned account")
        return row[0]


def conditional_save(path, account, snapshot, candidate, commit=True):
    return write_sql(path, "UPDATE credential SET verifier=? WHERE account=? AND verifier=?",
                     (candidate, account, snapshot), commit=commit)


def record(report, case, observed, expected):
    passed = observed == expected
    report["cases"].append({"case": case, "observed": observed,
                            "expected": expected, "pass": passed})
    require(passed, case + " did not match the declared oracle")


def run(report):
    for package in ("argon2-cffi", "argon2-cffi-bindings"):
        require(metadata.version(package) == "25.1.0", package + " version mismatch")
    report["environment"] = {
        "python": sys.version, "platform": platform.platform(),
        "argon2_cffi": metadata.version("argon2-cffi"),
        "argon2_cffi_bindings": metadata.version("argon2-cffi-bindings"),
        "cffi": metadata.version("cffi"), "sqlite": sqlite3.sqlite_version,
        "source_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
    }
    old = generate(hasher(2), PASSWORD)
    current = generate(TARGET, PASSWORD)
    other_policy = generate(hasher(4), PASSWORD)
    reset = generate(TARGET, RESET_PASSWORD)
    for encoded, iterations in ((old, 2), (current, 3), (other_policy, 4), (reset, 3)):
        require(profile(encoded) == expected_profile(iterations), "Profile setup mismatch")
    report["declared_profiles"] = {"source": expected_profile(2), "target": expected_profile(3),
                                   "non_allowlisted": expected_profile(4)}
    with tempfile.TemporaryDirectory(prefix="argon2-rehash-owned-") as directory:
        path = Path(directory) / "credentials.sqlite"
        write_sql(path, "CREATE TABLE credential(account TEXT PRIMARY KEY, verifier TEXT NOT NULL)")

        def seed(account, encoded=old):
            write_sql(path, "INSERT INTO credential VALUES(?,?)", (account, encoded))

        seed("verify_only")
        snapshot = read_hash(path, "verify_only")
        record(report, "C01_verify_is_not_migration", {
            "match": TARGET.verify(snapshot, PASSWORD),
            "needs_rehash": TARGET.check_needs_rehash(snapshot),
            "stored_unchanged": read_hash(path, "verify_only") == snapshot,
            "stored_time_cost": profile(read_hash(path, "verify_only"))["time_cost"]},
            {"match": True, "needs_rehash": True, "stored_unchanged": True, "stored_time_cost": 2})

        for case, account, encoded, password, decision in [
            ("C02_wrong_password", "wrong", old, WRONG, "NO_MATCH"),
            ("C03_malformed_verifier", "malformed", "not-an-argon2-hash", PASSWORD, "VERIFIER_ERROR"),
            ("C04_target_policy", "current", current, PASSWORD, "NO_REHASH_NEEDED"),
            ("C05_non_allowlisted_t4", "t4", other_policy, PASSWORD, "HOLD_POLICY_DIFFERENCE"),
        ]:
            seed(account, encoded)
            count = HASH_CALLS
            status, candidate = prepare(read_hash(path, account), password)
            observed = {"decision": status, "candidate_absent": candidate is None,
                        "hash_calls": HASH_CALLS - count, "stored_unchanged": read_hash(path, account) == encoded}
            expected = {"decision": decision, "candidate_absent": True, "hash_calls": 0, "stored_unchanged": True}
            if account == "t4":
                observed.update(match=TARGET.verify(encoded, PASSWORD), needs_rehash=TARGET.check_needs_rehash(encoded))
                expected.update(match=True, needs_rehash=True)
            record(report, case, observed, expected)

        seed("prepared")
        status, candidate = prepare(read_hash(path, "prepared"), PASSWORD)
        record(report, "C06_candidate_is_not_storage", {
            "decision": status, "candidate_matches": matches(candidate, PASSWORD),
            "candidate_profile": profile(candidate), "stored_unchanged": read_hash(path, "prepared") == old},
            {"decision": "PREPARED_NOT_STORED", "candidate_matches": True,
             "candidate_profile": expected_profile(3), "stored_unchanged": True})

        seed("normal")
        snapshot = read_hash(path, "normal")
        , candidate = prepare(snapshot, PASSWORD)
        rows = conditionalsave(path, "normal", snapshot, candidate)
        stored = read_hash(path, "normal")
        record(report, "C07_conditional_commit", {
            "rows": rows, "stored_candidate": stored == candidate, "match": matches(stored, PASSWORD),
            "profile": profile(stored), "needs_rehash": TARGET.check_needs_rehash(stored)},
            {"rows": 1, "stored_candidate": True, "match": True,
             "profile": expected_profile(3), "needs_rehash": False})
        count = HASH_CALLS
        status, candidate = prepare(read_hash(path, "normal"), PASSWORD)
        record(report, "C08_repeat_after_upgrade", {
            "decision": status, "candidate_absent": candidate is None, "hash_calls": HASH_CALLS - count,
            "stored_unchanged": read_hash(path, "normal") == stored},
            {"decision": "NO_REHASH_NEEDED", "candidate_absent": True, "hash_calls": 0, "stored_unchanged": True})

        for case, account, guarded in [("C09_unsafe_reset_overwrite", "unsafe", False),
                                      ("C10_reset_preserved", "guarded", True)]:
            seed(account)
            snapshot = read_hash(path, account)
            , candidate = prepare(snapshot, PASSWORD)
            writesql(path, "UPDATE credential SET verifier=? WHERE account=?", (reset, account))
            # The unguarded branch is an intentionally failing owned negative control.
            rows = (conditional_save(path, account, snapshot, candidate) if guarded else
                    write_sql(path, "UPDATE credential SET verifier=? WHERE account=?", (candidate, account)))
            stored = read_hash(path, account)
            record(report, case, {"rows": rows, "reset_preserved": stored == reset,
                                  "old_password_matches": matches(stored, PASSWORD),
                                  "reset_password_matches": matches(stored, RESET_PASSWORD)},
                   {"rows": 0 if guarded else 1, "reset_preserved": guarded,
                    "old_password_matches": not guarded, "reset_password_matches": guarded})


        seed("two_rehashes")
        snapshot = read_hash(path, "two_rehashes")
        , first = prepare(snapshot, PASSWORD)
        , second = prepare(snapshot, PASSWORD)
        first_rows = conditional_save(path, "two_rehashes", snapshot, first)
        second_rows = conditional_save(path, "two_rehashes", snapshot, second)
        stored = read_hash(path, "two_rehashes")
        record(report, "C11_competing_rehash", {"first_rows": first_rows, "second_rows": second_rows,
               "first_preserved": stored == first, "password_matches": matches(stored, PASSWORD),
               "needs_rehash": TARGET.check_needs_rehash(stored)},
               {"first_rows": 1, "second_rows": 0, "first_preserved": True,
                "password_matches": True, "needs_rehash": False})

        seed("rollback")
        snapshot = read_hash(path, "rollback")
        , candidate = prepare(snapshot, PASSWORD)
        rows = conditionalsave(path, "rollback", snapshot, candidate, commit=False)
        record(report, "C12_rowcount_is_not_commit", {
            "rows_before_rollback": rows, "stored_unchanged": read_hash(path, "rollback") == snapshot,
            "needs_rehash": TARGET.check_needs_rehash(read_hash(path, "rollback"))},
            {"rows_before_rollback": 1, "stored_unchanged": True, "needs_rehash": True})
    report["owned_directory_removed"] = not Path(directory).exists()
    require(report["owned_directory_removed"], "Owned cleanup failed")
    require(len(report["cases"]) == 12, "Incomplete case matrix")
    report["status"] = "PASS_12_CASES"


if name == "__main__":
    report = {"status": "HOLD", "cases": [], "started_utc": datetime.now(timezone.utc).isoformat()}
    exit_status = 0
    try:
        run(report)
    except Exception as error:
        report["failure"] = {"type": type(error).__name__, "message": str(error)}
        exit_status = 1
    report["finished_utc"] = datetime.now(timezone.utc).isoformat()
    print(json.dumps(report, indent=2))
    raise SystemExit(exit_status)

Check the process exit status immediately before treating the report as evidence. Then require PASS_12_CASES, exactly twelve cases in declared order, every pass flag true, the expected source SHA for the exact fixture file approved for that run, and owned_directory_removed=true. An import failure, altered source, assertion failure, unexpected exception, missing JSON, incomplete inventory, or failed cleanup is a HOLD, not a partial pass.

Python's maintained 3.12 documentation currently identifies itself as 3.12.16 documentation, whereas the supplied run recorded Python 3.12.14. That documentation is therefore a semantics reference, not proof of the interpreter used by the experiment. A different platform should likewise produce its own recorded run rather than copying the supplied observations.

Reject invalid candidates before generating replacements

C02 and C03 establish two rejection boundaries before any replacement hash exists.

For C02, the known wrong candidate reaches TARGET.verify() against a valid source verifier and produces the handled password-mismatch path. prepare() returns NO_MATCH, no candidate exists, HASH_CALLS does not increase, and the stored verifier is unchanged. argon2-cffi documents VerifyMismatchError specifically for the case where the supplied password does not match the verifier.

C03 is deliberately different. "not-an-argon2-hash" is malformed verifier material. The fixed-version API distinguishes clearly invalid encoded hashes and other verification errors from a password mismatch. The fixture maps the handled InvalidHashError/VerificationError path to VERIFIER_ERROR, again with no candidate, zero additional hash calls, and no storage change.

These are internal evidence classifications, not recommendations for distinct external authentication messages.

C04 supplies the third important no-write path. The password is valid and the verifier already has the target profile, so check_needs_rehash() is false. NO_REHASH_NEEDED means exactly that: the verified snapshot already agrees with this configured target. No candidate is manufactured.

A handled verification error is therefore never evidence that a replacement should be inferred or synthesized. Candidate generation begins only after successful verification, a positive rehash-difference check, and explicit approval of the source profile.

Prove that a prepared rehash has not been stored

C01 is the simplest rebuttal to a common shorthand: verify then rehash password is not one indivisible operation.

The source verifier matches PASSWORD; TARGET.check_needs_rehash(snapshot) is true; and a subsequent database read still returns the identical source value with time_cost=2. verify() did not migrate anything. The package how-to similarly presents verification, checking, hashing, and application persistence as separate operations.

C06 advances one stage. prepare() verifies the source, confirms that its complete decoded profile is the allowlisted t=2 profile, and calls TARGET.hash(password). The generated candidate successfully verifies the same known password and independently decodes to the exact target profile: Argon2id, version 19, 16-byte salt, 32-byte output, time_cost=3, memory_cost=65536, parallelism 1. Yet a database read still equals old.

PREPARED_NOT_STORED is therefore a useful positive intermediate state, not an error. It records three facts: the password matched the snapshot, policy approved that snapshot's transition, and a suitable candidate exists in process memory. It deliberately makes no persistence claim.

To move from that state to ACCEPT_REHASH_COMMITTED, evidence still has to show that the expected snapshot remained current at the write, that the update matched, that the transaction committed, and that an independent read retained the intended candidate.

Commit a replacement conditioned on the verified snapshot

The storage step binds the generated candidate, account identifier, and previously verified snapshot into one statement:

UPDATE credential
SET verifier=?
WHERE account=? AND verifier=?

The account column is the table's primary key; verifier=? adds the credential-specific current-value predicate. Python's sqlite3 documentation recommends parameter placeholders rather than string formatting for bound SQL values. SQLite's UPDATE documentation states that only rows whose WHERE expression evaluates true are affected and that matching zero rows is a valid outcome.

This resembles the general idea of write-time conditions in DynamoDB, but the contracts must not be conflated. That article concerns preventing an update-only operation from creating a missing DynamoDB item. This fixture has an existing SQLite credential row and asks whether its current verifier still equals a specific verified snapshot.

C07 gets rowcount 1 because the account and snapshot both still match. With autocommit=False, the fixture then calls commit() explicitly. Python documents explicit commit() and rollback() as the transaction controls in this mode and documents Cursor.rowcount as the number of rows modified for an UPDATE.

Check the committed verifier through a fresh connection

A rowcount of one is only the attempted-write stage. C07 next opens a new connection and requires four retained-state properties: the stored string equals the generated candidate; its decoded profile equals the exact t=3 target; the known password verifies; and check_needs_rehash(stored) is false.

Only that chain justifies the operational disposition ACCEPT_REHASH_COMMITTED in this laboratory.

The fresh connection matters because closing a connection and committing are distinct operations in Python's API; when autocommit=False, pending work must be committed or it can be rolled back on close.

This is nevertheless only local committed readback. It does not prove session admission, another database engine's behavior, replication semantics, or survival of a host crash or power loss.

Reproduce the late rehash that undoes a reset

C09 intentionally implements the broken design. Its sequence is controlled and sequential:

  1. Read the old t=2 verifier into snapshot.

  2. Verify PASSWORD and prepare a fresh t=3 candidate derived from that old password.

  3. Store reset, a different t=3 verifier created from RESET_PASSWORD.

  4. Ignore the earlier snapshot and blindly run UPDATE credential SET verifier=? WHERE account=? with the stale candidate.

  5. Commit and read the credential back.

The blind update reports one affected row. That fact is technically unsurprising: its WHERE clause asks only whether the account row exists, so it is allowed to replace whatever verifier is current. SQLite defines UPDATE in terms of rows satisfying the WHERE expression; the statement contains no memory of the verifier that was checked earlier.

The discriminating evidence comes afterward. reset_preserved is false. The retained verifier accepts the old PASSWORD and rejects RESET_PASSWORD. The rehash has therefore undone the intervening reset.

C09's test flag is still pass=true because the fixture was designed to reproduce this unsafe result. That PASS must never be confused with application acceptance. The correct operational disposition for such a retained state is RECONCILE_UNSAFE_OVERWRITE.

Nothing simultaneous occurred. The Argon2 password reset race is modeled here as a deterministic schedule of ordinary calls: old read, candidate preparation, reset, late write. That is enough to demonstrate the stale-write defect without claiming exhaustive concurrency testing.

Preserve the newer verifier when the snapshot changed

C10 repeats C09's schedule and changes exactly one important storage decision.

It reads old, successfully prepares a candidate from PASSWORD, and then installs the reset verifier generated from RESET_PASSWORD. At this point the prepared rehash is stale: it was derived from a credential state that is no longer current.

Instead of updating by account alone, C10 calls:

UPDATE credential
SET verifier=?
WHERE account=? AND verifier=?

with the original snapshot as the expected verifier. The account still exists, but its current verifier is now reset, so the complete predicate does not match. SQLite permits this ordinary zero-row outcome. Python exposes that outcome through the update cursor's rowcount.

Fresh readback then supplies the crucial evidence: rowcount is zero; the retained value equals reset; the old PASSWORD no longer matches it; and RESET_PASSWORD does. The stale candidate did not replace the newer credential.

The immediate operational disposition is HOLD_CHANGED_CREDENTIAL, not ACCEPT_REHASH_COMMITTED and not “retry.” Rowcount zero establishes only that the expected account-plus-verifier snapshot did not match at update time. C11 will show that a competing rehash can produce the same rowcount.

The safe next step is to retain the current value and perform a new application decision from current state. Do not reread a different verifier and then reuse the candidate generated from the old password merely by changing the expected predicate. That would remove the evidence binding between the successful verification and the storage transition.

Likewise, rejection of the stale write does not retroactively authorize a login session. This laboratory never evaluates account disablement, session policy, additional authentication requirements, or whether an earlier request remains admissible after credential state changes.

Bound what the old-hash predicate can observe

The predicate is deliberately modest. It asks whether the encoded verifier currently equals the exact encoded value previously read. Within C10, that is enough to prevent the demonstrated stale candidate from overwriting the reset.

It does not describe the entire history of the credential. An untested ABA sequence could change the value and later restore the exact same encoded bytes; exact-value equality would not reveal that history. The fixture also contains no credential epoch, timestamp, session generation, account-status predicate, or authorization column.

Those are important boundaries, not invitations to turn this article into a generalized optimistic-locking design. The demonstrated claim is only that conditioning this SQLite update on the old encoded verifier detects the selected interleavings when the current bytes differ.

Application integrations that need stronger history or authorization semantics must qualify those mechanisms separately.

Reconcile competing rehashes and rolled-back updates

C11 explains why rows == 0 cannot diagnose a password reset.

Two replacement candidates are independently prepared from the same valid t=2 snapshot and the same public synthetic password. Because the library generates fresh salts, the candidates need not have identical encoded bytes even though both have the same declared target parameters. PasswordHasher.hash() uses a random salt when none is supplied.

The first conditional save finds the original snapshot and returns rowcount 1. It commits. The second save still expects that old snapshot, but the first candidate is now current, so the second call returns zero. Fresh readback equals the first candidate; the password matches it; and the target hasher reports no rehash needed.

A zero-row conditional password hash update can therefore mean at least two materially different things in this finite matrix: C10's different-password reset or C11's competing same-password rehash. The rowcount does not identify the cause.

After zero rows, preserve current storage and re-evaluate it under application policy. A fresh decision may find an already acceptable target verifier, as C11 does. What is unsafe is blindly replaying a stale candidate while relaxing its original expected-state condition.

Keep rowcount separate from commit evidence

C12 isolates another boundary. Its conditional UPDATE successfully matches the old snapshot and reports rows_before_rollback=1. The fixture then explicitly calls rollback() rather than commit().

Python's sqlite3 transaction documentation distinguishes those operations when autocommit=False.

A fresh connection subsequently sees the original verifier, and check_needs_rehash() remains true. Thus rowcount 1 proves that the update matched and executed inside the transaction; it is insufficient evidence that the replacement became committed state.

This rollback case does not simulate a password reset, a crash, filesystem failure, or power-loss durability. It demonstrates only an explicit transaction rollback followed by ordinary readback.

Read the evidence ledger and assign a disposition

The supplied reference evidence reports agreement between observed and declared expected values in all twelve cases:

Case

Discriminating evidence

Evidence interpretation

C01

Match true; rehash true; stored t=2 unchanged

Verification is not migration

C02

NO_MATCH; no candidate; zero hashes

Reject replacement path

C03

VERIFIER_ERROR; no candidate; zero hashes

Hold malformed/error path

C04

NO_REHASH_NEEDED; unchanged

Current target profile

C05

Match true; rehash true; t=4 held

HOLD_POLICY_DIFFERENCE

C06

Candidate matches, is t=3; old retained

PREPARED_NOT_STORED

C07

Rows 1; commit; readback=candidate

ACCEPT_REHASH_COMMITTED

C08

Repeat needs no rehash; zero hashes

Stable target no-op

C09

Rows 1; reset lost; old password works

Unsafe negative control

C10

Rows 0; reset retained; reset password works

HOLD_CHANGED_CREDENTIAL

C11

Rows 1 then 0; first candidate retained

Competing rehash explained

C12

Rows 1 before rollback; old retained

Rowcount is not commit

The acceptance playbook is therefore reproducible and deliberately evidence-driven. First classify verification as NO_MATCH or VERIFIER_ERROR when applicable. After a match, return NO_REHASH_NEEDED for the exact target profile or HOLD_POLICY_DIFFERENCE for a difference outside the approved source profile. Only an allowlisted source may reach PREPARED_NOT_STORED.

After preparation, ACCEPT_REHASH_COMMITTED requires all of the storage evidence, not merely a candidate or rowcount: expected source/profile approval, a one-row conditional update, explicit commit, fresh read equal to the candidate, exact target profile, successful known-password verification, and needs_rehash=False.

A zero-row conditional write is HOLD_CHANGED_CREDENTIAL until current state is re-evaluated; do not invent whether the cause was a reset or another rehash. C09's successful reproduction of a blind overwrite instead demands RECONCILE_UNSAFE_OVERWRITE.

A release evidence packet should also require the exact twelve-case inventory, expected source identity, no writes on rejected paths, C10 reset preservation, C12 rollback retention, and successful removal of the owned temporary directory. Fixture PASS and acceptable credential state remain different verdicts.

Recover without reviving an obsolete password

Three kinds of rollback should not be conflated.

A code rollback can disable a faulty opportunistic-rehash implementation. A transaction rollback, such as C12, cancels uncommitted work in that transaction. Credential recovery determines what password verifier should be authoritative after an uncertain or erroneous credential transition. Only the first two are demonstrated mechanically here.

If an implementation has already produced C09's unsafe state, restoring some previously captured old verifier is not a sound general recovery rule. The old material may itself be obsolete because the credential owner intentionally reset the password. Recovery has to start from the application's current authorized identity-recovery or reset process, not from an assumption that a historically valid verifier deserves to become current again.

That boundary belongs with explicit incident response ownership: assign who can halt the faulty path, who assesses affected credential state, who authorizes recovery, and what evidence must be retained. The linked article discusses defined incident-response roles and recovery responsibilities; it provides context for assigning those duties without validating this fixture's technical behavior.

Preserve decision evidence such as code revision, dependency versions, declared profiles, case outcomes, update result, and appropriate application audit records. Do not turn a response artifact into a collection of raw passwords or production verifier dumps.

The synthetic SQLite database is not a production recovery plan. This laboratory implements no identity proofing, password-reset delivery, session revocation, rate limiting, or affected-account discovery. Those questions remain owned by the integrating application.

Own the release gate and its revalidation triggers

PASS_12_CASES is most useful as a bounded regression gate, not as a declaration that credential handling is solved.

A release review should pin the source identity and relevant dependency versions; verify all twelve cases are present in the intended order; declare the complete source, target, and held profiles independently; preserve the C09 negative control; and require both C10 reset preservation and C12 rollback evidence. Those checks keep a future refactor from reducing “success” to a password match or an UPDATE rowcount.

This type of focused fixture fits alongside automated security testing in CI, where repeatable security checks are integrated with delivery workflows. That adjacent guidance also emphasizes that automated testing does not replace review of design and logic boundaries.

Requalification is warranted when the Argon2 library or wrapper changes, the approved parameter transition changes, the stored-verifier schema or SQL predicate changes, transaction handling changes, or the database behavior relied upon by the adapter changes. A package update should be evaluated against its own documented behavior rather than assuming that this 25.1.0 result transfers automatically.

Production integration needs additional review when session admission, account disablement, reset flows, authorization state, or multiple writers alter the surrounding contract. The current-value predicate's exact-byte ABA limitation also remains untested.

The supplied run's PASS_12_CASES establishes only the declared matrix under its recorded local environment and scheduled interleavings. It does not establish throughput, all SQLite locking schedules, simultaneous threads, fleet migration, distributed consistency, or crash durability.

Assign credential, policy and storage ownership

The parameter-policy owner should approve exactly which encoded source profiles may transition to which targets. The Python credential maintainer should preserve the binding from verified snapshot to conditional persistence. The application-security reviewer should assess current-state and session implications that this fixture intentionally omits. The incident owner should control recovery when retained credential state is uncertain.

Each decision should cite its evidence boundary: password match, policy comparison, candidate generation, conditional rowcount, transaction outcome, or committed readback.

That division matters because no single boolean carries all those meanings. verify=True, check_needs_rehash=True, rowcount=1, and PASS_12_CASES each answer different questions.

Develop secure workflow judgment with Refonte Learning

The durable lesson in Argon2 rehash persistence is not a particular SQL trick. It is the discipline of separating evidence: what credential was checked, what parameter transition was approved, what candidate was generated, whether the expected state still existed, whether the write committed, and what credential a new read actually retained.

Refonte Learning's Cybersecurity & DevSecOps programme lists cryptography and secure data management, encryption, secure CI/CD pipelines, DAST/SAST/IAST, OWASP and threat modeling, incident response, and security-workflow automation among its curriculum areas. Its page also describes a virtual-internship offering and lists a Training Certificate and Certificate of Internship; Dr. Christine Baker is identified there as an educational mentor.

Those confirmed topics align naturally with the judgment exercised in this laboratory: inspect a security API's actual contract, distinguish policy from mechanism, test negative state transitions, and make recovery ownership explicit. They should not be read as a claim that the programme teaches this exact Argon2/SQLite fixture, these package versions, or a particular production parameter profile.

For practitioners looking to strengthen that broader security-review and secure-workflow discipline, the programme provides a relevant next point of study without changing the central operational rule demonstrated here: a rehash candidate derived from an old verifier may replace it only while that verified verifier is still the value the conditional write finds current.