Python security engineers reviewing bcrypt 4.3.0 and 5.0.0 password compatibility at the 72-byte UTF-8 boundary

A bcrypt Upgrade Can Reject a Previously Accepted Password

Mon, Oct 12, 2026

A dependency upgrade installs successfully, the application starts, and a short synthetic password still verifies. That success can conceal a narrower authentication change: a longer candidate accepted against an existing bcrypt hash can raise an exception after the package changes. A healthy import and one successful login test do not establish the password acceptance contract.

For Python backend engineers, application-security reviewers and credential-lifecycle owners, the question is precise: does identical stored verifier material accept and classify the same encoded candidate under pyca/bcrypt 4.3.0 and 5.0.0? This article examines bcrypt upgrade password compatibility at the UTF-8 boundary, with new-password enrollment kept separate from verification of existing hashes.

The commissioning record supplied for this article reports a run on 2026-10-12 using CPython 3.12.14 on Linux x86_64. It compared nine synthetic candidates per package against four shared legacy hashes and included one malformed-hash control per package. Its observations are attributed to that run; the package experiment has not been independently rerun for this article. The listed primary sources were rechecked on the commissioning date.

The complete protocol below lets another environment reproduce the comparison and retain its own evidence. It supports a conditional decision: accept a defined input contract, hold an incompatible rollout, or prepare controlled credential recovery. A laboratory PASS confirms the finite expected matrix. It does not establish universal compatibility, identify affected accounts, or approve deployment by itself.

Define the password acceptance contract before upgrading

Start with an application that already stores bcrypt hashes. Its authentication owner needs to describe what happens when a candidate reaches the verifier, including candidates that earlier code may have accepted through truncation. The unit of review is the candidate, its encoded bytes, the selected stored hash and the resulting classification.

Enrollment asks whether a new password may become verifier material. Verification asks whether a supplied candidate matches a particular existing verifier. A dependency can change the first operation, the second operation, or their failure behavior. Passing an enrollment test cannot answer all three questions.

Write separate acceptance statements for enrollment and existing-hash verification. For example, a team might have enforced a byte maximum during recent enrollment while older accounts were created under different rules. Applying today's rule to every verification request would still affect historical behavior. Record which population a statement covers and whether the supporting rule was enforced at the server boundary throughout that population's lifecycle.

Declare four internal outcomes before inspecting results:

Outcome

Meaning in this bounded adapter

Credential match established?

MATCH

An admitted candidate returned True from checkpw

Yes, for that verifier

NO_MATCH

An admitted candidate returned False

No

OVER_LIMIT

UTF-8 encoding exceeds 72 bytes; verification is not attempted

No

VERIFIER_ERROR

An admitted candidate encountered the handled verifier exception

No

These names are internal evidence categories. They do not prescribe four public authentication messages, and MATCH does not perform account-status checks, establish a session or satisfy additional authentication requirements.

For this playbook, the proposed contract admits candidates of at most 72 UTF-8 bytes to bcrypt, preserves ordinary mismatches, and prevents exceptions from becoming successful checks. That is an explicit policy proposal. If the existing application promises continued acceptance of longer candidates, this proposal changes its behavior and needs a recovery or compatibility decision.

Readers needing surrounding authentication concepts can use Refonte Learning's backend authentication foundations. Here, the starting point is an existing credential population. The release question is whether its documented acceptance rules remain defensible after one Python package changes.

Pin the Python library boundary and its dates

The historical baseline is pyca/bcrypt 4.3.0 on PyPI, released on 2025-02-28. The comparison target is pyca/bcrypt 5.0.0 on PyPI, released on 2025-09-25. These are 2025 releases being qualified for an existing application on 2026-10-12. Neither version is presented here as the newest release or a generally preferred deployment choice.

Keep one recorded CPython interpreter for both workers. The commissioning baseline used 3.12.14; a reproduction must record its actual interpreter instead of borrowing that version label. Each subprocess uses the parent's sys.executable, while its import path selects one exact package target.

This arrangement narrows the changing variable. Updating Python, adding a framework adapter and replacing bcrypt simultaneously would leave several explanations for a changed result. A successful installation alone proves neither that the intended module was imported nor that the expected credentials were checked. Inspect the reported package version and resolved module path.

Keep three dates distinct in the evidence packet. A release date describes when the maintainer published a package. A source-access date describes when a document was inspected. An execution timestamp describes when a particular environment produced observations. None substitutes for the others, and a later editorial review cannot turn the earlier release into a newly launched feature.

Resolve the changelog and older prose conflict explicitly

The 5.0.0 changelog states that hashpw now raises ValueError for inputs longer than 72 bytes, where earlier behavior silently truncated them. The same page's older maximum-password prose still describes 72 characters and ignored excess input. That discrepancy remains visible; its cause is not established here.

Use the versioned change for the documented hashpw boundary. Attribute the checkpw results below to the supplied experiment, because that changelog sentence does not explicitly describe checkpw. A Python bcrypt 5.0.0 ValueError therefore needs context: which operation received which input, and what does the application do with the exception?

Measure encoded bytes at the actual verifier boundary

The bcrypt 72 byte limit concerns the bytes submitted to the library. Python string length and encoded length answer different questions. The Python 3.12 Unicode HOWTO explains that UTF-8 represents a code point using one to four bytes and that str.encode() produces the encoded byte representation.

The fixture explicitly uses plain.encode("utf-8"), with no normalization, trimming or replacement. Its é is U+00E9; its smiling emoji is U+1F642. Those choices fix the input representation so a library result cannot quietly redefine the expected length.

Keep the character count out of the byte-limit oracle

The declared boundaries are:

Synthetic value

len(value) in code points

len(value.encode("utf-8")) in bytes

"A" * 71

71

71

"A" * 72

72

72

"A" * 72 + "X"

73

73

"\u00e9" * 36

36

72

"\u00e9" * 37

37

74

"\U0001f642" * 18

18

72

"\U0001f642" * 19

19

76

The script checks both counts against constants declared independently of bcrypt's responses. This is the oracle: expected data against which observations are compared. Calculating an expected outcome from the observed exception would make the test agree with itself.

For a candidate named value, compare len(value) with len(value.encode("utf-8")) before interpreting the library outcome. A 37-code-point maximum check would admit the 37-é candidate even though it occupies 74 bytes. Conversely, rejecting every 37-code-point input would unnecessarily exclude the 37-A sequence from this particular byte rule. The examples explain why the measured unit must match the contract; they do not prescribe a complete enrollment policy.

Code points also differ from grapheme clusters, which concern user-perceived characters. This fixture does not count grapheme clusters or qualify visually equivalent spellings. It establishes UTF-8 password byte length for these exact strings. A universal character-count maximum cannot be derived from these examples, and changing normalization would require a separate credential-compatibility review.

Prepare identical legacy hashes for both environments

Create a fresh local laboratory directory. Save the complete script below there as bcrypt_boundary_lab.py. Use synthetic inputs exactly as supplied; never substitute a credential dump. After saving it, run these commands with the same CPython interpreter:

python3 --version
python3 -m pip install --no-deps --target ./bcrypt-4.3.0 'bcrypt==4.3.0'
python3 -m pip install --no-deps --target ./bcrypt-5.0.0 'bcrypt==5.0.0'
python3 bcrypt_boundary_lab.py --old ./bcrypt-4.3.0 --new ./bcrypt-5.0.0

The target directories must be new. Installation requires package-download access or pre-provisioned exact packages. Keep the legacy package out of the application's environment. An unavailable package, unsupported installation or failed worker leaves that environment unqualified; do not substitute another version.

The old worker creates four reference hashes: 71 As, 72 As, 36 é code points and 18 emoji. All four reference passwords are within the byte boundary. The parent passes those exact hash strings to the new worker. Long candidates are checked against selected references; no long-enrollment hash is retained for a separate round trip.

Generating fresh references independently in both workers would answer a different question. It could demonstrate each package's own creation-and-verification path while failing to compare identical stored material. Here, the old worker owns reference creation and the new worker consumes its output. The reference field also matters: a candidate can only be interpreted against the intended reference, especially when the wrong-password control deliberately selects a different value from its candidate.

"""Compare two isolated pyca/bcrypt installs using synthetic passwords only."""
import argparse
import hashlib
import json
import platform
import subprocess
import sys
import tempfile
import traceback
from datetime import datetime, timezone
from pathlib import Path
CASES = [
    ("ascii71", "A" * 71, "a71", 71, 71, True),
    ("ascii72", "A" * 72, "a72", 72, 72, True),
    ("ascii73_x", "A" * 72 + "X", "a72", 73, 73, True),
    ("ascii73_y", "A" * 72 + "Y", "a72", 73, 73, True),
    ("ascii72_wrong", "B" + "A" * 71, "a72", 72, 72, False),
    ("utf8_36", "\u00e9" * 36, "u36", 36, 72, True),
    ("utf8_37", "\u00e9" * 37, "u36", 37, 74, True),
    ("emoji18", "\U0001f642" * 18, "e18", 18, 72, True),
    ("emoji19", "\U0001f642" * 19, "e18", 19, 76, True),
]
REFERENCE = {"a71": "A" * 71, "a72": "A" * 72, "u36": "\u00e9" * 36, "e18": "\U0001f642" * 18}
def require(condition, message):
    if not condition:
        raise RuntimeError(message)
def worker(package_dir):
    sys.path.insert(0, str(Path(package_dir).resolve()))
    import bcrypt
    request = json.load(sys.stdin)
    require(bcrypt.__version__ == request["expected_version"], "Wrong bcrypt installation")
    hashes = request.get("hashes")
    if hashes is None:
        hashes = {key: bcrypt.hashpw(value.encode("utf-8"), bcrypt.gensalt(rounds=10)).decode("ascii") for key, value in REFERENCE.items()}
    result = {"version": bcrypt.__version__, "module": str(Path(bcrypt.__file__).resolve()), "python": sys.version, "reference_digests": {key: hashlib.sha256(value.encode("ascii")).hexdigest() for key, value in hashes.items()}, "cases": []}
    # The cost is fixed for this compatibility experiment, not benchmark guidance.
    test_salt = bcrypt.gensalt(rounds=10)
    for name, plain, reference, codepoints, byte_length, legacy_match in CASES:
        raw = plain.encode("utf-8")
        require(len(plain) == codepoints and len(raw) == byte_length, "Fixture length mismatch")
        stored = hashes[reference].encode("ascii")
        row = {"case_id": name, "unicode_codepoints": len(plain), "utf8_bytes": len(raw), "reference": reference}
        try:
            bcrypt.hashpw(raw, test_salt)
            row["hashpw"] = "OK"
        except ValueError:
            row["hashpw"] = "ValueError"
        try:
            row["checkpw"] = bcrypt.checkpw(raw, stored)
        except ValueError:
            row["checkpw"] = "ValueError"
        # This deliberately bounded adapter does not implement a complete login system.
        if len(raw) > 72:
            row["adapter"] = "OVER_LIMIT"
        else:
            try:
                row["adapter"] = "MATCH" if bcrypt.checkpw(raw, stored) else "NO_MATCH"
            except ValueError:
                row["adapter"] = "VERIFIER_ERROR"
        result["cases"].append(row)
    try:
        bcrypt.checkpw(b"synthetic", b"not-a-bcrypt-hash")
        result["malformed_hash"] = "UNEXPECTED_RETURN"
    except ValueError:
        result["malformed_hash"] = "VERIFIER_ERROR"
    result["hashes"] = hashes
    print(json.dumps(result))
def invoke(package_dir, request):
    process = subprocess.run([sys.executable, str(Path(__file__).resolve()), "--worker", str(package_dir)], input=json.dumps(request), text=True, capture_output=True, timeout=45)
    require(process.returncode == 0, "Worker failed: " + process.stderr[-1000:])
    return json.loads(process.stdout)
def main(old_dir, new_dir):
    directory = Path(tempfile.mkdtemp(prefix="bcrypt-boundary-", dir=Path(__file__).parent)).resolve()
    report = {"status": "HOLD", "started_at": datetime.now(timezone.utc).isoformat(), "platform": platform.platform(), "fixture_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), "scope": "Synthetic compatibility fixture; no real accounts, passwords or credential stores", "hash_cost_parameter": 10}
    try:
        old = invoke(old_dir, {"expected_version": "4.3.0"})
        hashes = old.pop("hashes")
        report["old"] = old
        new = invoke(new_dir, {"expected_version": "5.0.0", "hashes": hashes})
        new.pop("hashes")
        report["new"] = new
        require(old["reference_digests"] == new["reference_digests"], "Reference hashes changed")
        expected_ids = [case[0] for case in CASES]
        for observed in (old, new):
            require([case["case_id"] for case in observed["cases"]] == expected_ids, "Case coverage mismatch")
            require(observed["malformed_hash"] == "VERIFIER_ERROR", "Malformed hash not classified")
            for row, fixture in zip(observed["cases"], CASES):
                name, plain, reference, codepoints, length, legacy_match = fixture
                over_limit = length > 72
                strict_new = observed["version"] == "5.0.0" and over_limit
                require(row["hashpw"] == ("ValueError" if strict_new else "OK"), "Unexpected hashpw: " + name)
                require(row["checkpw"] == ("ValueError" if strict_new else legacy_match), "Unexpected checkpw: " + name)
                expected_adapter = "OVER_LIMIT" if over_limit else ("MATCH" if legacy_match else "NO_MATCH")
                require(row["adapter"] == expected_adapter, "Wrong adapter decision: " + name)
        report["status"] = "PASS_18_VERSION_CASES_PLUS_2_MALFORMED_CONTROLS"
    except Exception as exc:
        report["status"] = "FAIL"
        report["error"] = {"type": type(exc).__name__, "message": str(exc)}
        report["traceback"] = traceback.format_exc()
        raise
    finally:
        report["finished_at"] = datetime.now(timezone.utc).isoformat()
        output = directory / "results.json"
        output.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
        print(json.dumps({"status": report["status"], "report": str(output)}))
if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--worker")
    parser.add_argument("--old", default="bcrypt-4.3.0")
    parser.add_argument("--new", default="bcrypt-5.0.0")
    args = parser.parse_args()
    if args.worker:
        worker(args.worker)
    else:
        main(Path(args.old).resolve(), Path(args.new).resolve())

Each run writes results.json inside a new bcrypt-boundary-... directory beside the script. Random salts produce different reference hash bytes in later runs. Require agreement between the workers' four fingerprints within one run, not equality to a previous run. Those fingerprints establish reference identity for this comparison; they do not attest to package provenance or credential security.

Test the ASCII boundary and the ignored suffix

Begin with ascii71 and ascii72. They contain 71 and 72 bytes, respectively, and each is checked against its corresponding old-version reference. The supplied run returned True under both packages. These positive controls establish the expected behavior at and immediately below the selected boundary.

Next, keep the 72-A reference and append X to the candidate. Repeat with Y. Each candidate contains 73 bytes. Under 4.3.0, both returned True against the same stored reference. Under 5.0.0, both raised ValueError. The reference fingerprints remained identical across workers, so replacing verifier material does not explain this difference.

This is the concrete compatibility counterexample. Two distinct supplied strings reached successful verification under the tested legacy behavior; the newer package rejected both calls. Their differing suffixes also show why a successful legacy check cannot establish which suffix was intended. The experiment demonstrates an acceptance change, without making an incidence claim about real users.

The hashpw column answers a separate question. It accepted every ASCII candidate under 4.3.0, including both 73-byte inputs. Under 5.0.0, those two calls raised ValueError. The script discards hashes produced by these probing calls; it continues to verify against the original selected reference.

Notice that exactly 72 bytes remain on the admitted side of the proposed rule. An application precheck using >= 72 would reject the positive boundary case and implement a different contract. The fixture's > 72 comparison makes that distinction executable. A reviewer investigating bcrypt 5 password length failures should therefore inspect the comparison operator as well as the encoding operation.

Keep a wrong short password as a control

ascii72_wrong changes the first byte: B followed by 71 As, checked against the 72-A reference. Both packages returned False, while hashpw accepted the candidate in both packages. Hash creation succeeds because it does not ask whether that password matches another verifier.

This control prevents every unsuccessful login result from being collapsed into one library behavior. A normal mismatch completed verification and returned False. An exception interrupted that operation. Catching a ValueError does not make it an alternative spelling of False, even when both ultimately result in a generic public failure.

Repeat the comparison with multibyte passwords

The ASCII cases make the threshold visible because one code point occupies one byte. Multibyte controls prevent an application from treating that convenient equality as a general password rule. These comparisons concern exact synthetic sequences, with the same UTF-8 encoding performed in each worker.

For utf8_36, 36 occurrences of U+00E9 occupy 72 bytes. The corresponding u36 reference was generated by 4.3.0, and both workers returned True. For utf8_37, one additional occurrence brings the candidate to 74 bytes. It was checked against the unchanged 36-occurrence reference. The supplied observations were True under 4.3.0 and ValueError under 5.0.0.

The emoji pair follows the same structure. Eighteen occurrences of U+1F642 occupy 72 bytes and match the e18 reference in both packages. Nineteen occupy 76 bytes, match that same reference under 4.3.0, and raise ValueError under 5.0.0. A comparatively small string length therefore says little about proximity to this byte boundary.

For both pairs, hashpw accepted both lengths under 4.3.0. Version 5.0.0 accepted the 72-byte value and raised ValueError for the longer value. These enrollment probes remain separate from the legacy-reference comparisons.

Together, the cases make bcrypt Unicode password compatibility concrete: the limiting representation is the encoded candidate, and the expected lengths are known before bcrypt runs. They do not qualify every Unicode sequence, combining-mark arrangement, encoding failure or boundary that falls inside a multibyte sequence. Here, the 72-byte prefix ends at a complete code point in every long example.

Read the Unicode rows alongside the ASCII mismatch instead of treating each positive row as complete password validation. The multibyte candidates were deliberately selected to match their legacy prefixes. The fixture does not include a separate wrong-é or wrong-emoji candidate. Its one ordinary mismatch establishes that control for the declared dataset, while additional application inputs would need their own independent expected results.

Keep those limits in the review record. Adding different Unicode handling to make a failing example pass would introduce another behavior change and obscure the package comparison. Extend the fixture deliberately when a different application representation needs qualification.

Classify verifier outcomes without admitting a failed check

The bounded adapter checks len(raw) > 72 first. An over-limit candidate becomes OVER_LIMIT without calling checkpw. Otherwise, True becomes MATCH, False becomes NO_MATCH, and the handled ValueError becomes VERIFIER_ERROR. This order is part of the declared contract.

The adapter consequently behaves the same in both package workers for the nine supplied inputs. That consistency comes from an explicit input restriction. It changes the acceptance of candidates that previously reached successful truncated verification. It is not a seamless migration and does not recover missing password information.

The worker deliberately runs the raw library probes before evaluating the adapter branch, so the report can compare both behaviors. That instrumentation is not a suggested request handler. Within the adapter, the length check prevents a verifier call for over-limit inputs; within the laboratory, the earlier raw call is necessary to observe the version difference. Keep those two purposes separate when reading the code.

The malformed-hash control calls checkpw with the short candidate b"synthetic" and deliberately invalid stored material. The supplied run raised ValueError in both versions, which the control classified as VERIFIER_ERROR. This demonstrates why exception handling cannot identify every failure as a wrong password.

Bcrypt malformed hash handling also needs precise diagnosis. The control establishes one cause of the caught exception; it does not prove that every possible ValueError has that cause. Unexpected exception types propagate and fail this laboratory rather than being converted into credential success.

When adapting these classifications into application code, make the credential-match branch explicitly require MATCH. Do not let an outcome label, the presence of a result object, or completion of an exception handler stand in for that comparison. Preserve the other outcomes through the caller until the appropriate failure or recovery handling has been selected. This is a review criterion; the fixture itself does not implement that caller.

Because length is checked first, a long candidate paired with malformed stored material would receive OVER_LIMIT under this adapter. The nine-row matrix and separate malformed controls do not execute that combined case. This policy does not inspect all possible problems in one request.

Preserve useful internal evidence and generic public failure

Use synthetic case IDs, version identities and outcome counts to explain the demonstration. The OWASP Authentication Cheat Sheet recommends generic authentication responses that avoid revealing whether an account exists. Internal authentication verifier error classification can remain more specific without exposing raw candidates, stored hashes or detailed exception messages to unauthenticated callers.

Broader operational practices belong with API security and observability. This experiment establishes no timing, enumeration or resource-exhaustion defense. A generic message alone cannot demonstrate those properties, and the adapter's early return makes a separate integration review particularly relevant.

Read the version-by-input matrix as a release gate

The following matrix records the supplied commissioning observations from 2026-10-12, not new execution output. OK means the hashpw probe returned without the caught exception. References are defined in the script; both workers used the same four reference fingerprints.

Candidate

Bytes

4.3.0
hashpw

4.3.0
checkpw

5.0.0
hashpw

5.0.0
checkpw

Adapter
(both)

A × 71

71

OK

True

OK

True

MATCH

A × 72

72

OK

True

OK

True

MATCH

A × 72, then X

73

OK

True

ValueError

ValueError

OVER_LIMIT

A × 72, then Y

73

OK

True

ValueError

ValueError

OVER_LIMIT

B, then A × 71

72

OK

False

OK

False

NO_MATCH

é × 36

72

OK

True

OK

True

MATCH

é × 37, against u36

74

OK

True

ValueError

ValueError

OVER_LIMIT

U+1F642 × 18

72

OK

True

OK

True

MATCH

U+1F642 × 19, against e18

76

OK

True

ValueError

ValueError

OVER_LIMIT

Separate malformed-hash control

Raw checkpw outcome

Classification

4.3.0, short synthetic candidate

ValueError

VERIFIER_ERROR

5.0.0, short synthetic candidate

ValueError

VERIFIER_ERROR

The commissioning record reports exit 0 and PASS_18_VERSION_CASES_PLUS_2_MALFORMED_CONTROLS. That means nine candidate cases under each version, plus two malformed controls. Multiple operations appear within each candidate case; these are not 20 independent production accounts.

The recorded commissioning fixture SHA-256 is 385f8b6cb2c5c9ac880d649853281259a0891c0c2203f234f680daadb46ea2f5. It identifies that supplied source artifact. A saved or reformatted copy has its own fixture_sha256; do not assert byte-for-byte identity without checking. Cost 10 is a fixed laboratory parameter, with no benchmark or universal recommendation attached.

Before interpreting a reproduction as qualified evidence:

  1. Confirm the exact package versions and that reported module paths belong to the intended targets.

  2. Confirm all ordered case IDs and four shared reference fingerprints.

  3. Require the expected operation results, both malformed controls and successful process completion.

  4. Retain the generated report and record the application contract separately.

The script asserts package versions but does not assert target-path containment or verify downloaded artifact provenance. Its default HOLD becomes FAIL on caught errors, with a nonzero exit. Missing rows, package mismatches, invalid worker JSON or timeouts are laboratory failures. An expected PASS deliberately includes the four changed long-input outcomes.

Across the nine raw checkpw probes, the supplied old worker produced eight matches and one mismatch. The new worker produced four matches, one mismatch and four exceptions. Those counts reconcile the rows; they describe selected synthetic candidates, not a failure rate. Reading only the final PASS string would conceal this expected change unless the reviewer also inspects the versioned outcomes.

Identify what an existing hash cannot tell you

An existing bcrypt string does not provide a reliable record of the original password's length or of a suffix ignored during the tested legacy processing. That limit matters when an owner asks which accounts will be affected. Comparing stored strings cannot reconstruct evidence that enrollment never preserved.

The two ASCII suffix cases provide a bounded demonstration. The 72-A candidate, the same prefix followed by X, and the same prefix followed by Y all returned True against the same reference under 4.3.0. The successful result therefore cannot distinguish those supplied candidates within this tested group.

This is an equivalence-class observation: several inputs share an acceptance result for the selected verifier. It does not reveal a historical user's intended suffix. Recovery cannot ask the hash which of these strings was originally entered and receive an authoritative answer.

Do not describe the fixture as a population audit. Its four references were intentionally created from within-boundary inputs. Its long candidates demonstrate changed verification behavior and its separate probes demonstrate hashpw behavior. No real enrollment records, account histories or user-entered passwords were examined.

Separate observed compatibility from credential-population assumptions

A synthetic long candidate matching a prefix is not evidence that a real user selected that candidate. Equally, a test suite containing only short passwords does not establish that longer inputs were always excluded. Both claims require application evidence beyond this experiment.

Relevant evidence might include a reviewed historical server-side input contract and independently authorized records of when enforcement changed. Such evidence needs its own access and retention rules. This laboratory provides no reason to collect passwords, export hashes into the fixture, or estimate affected-account prevalence.

For bcrypt 4.3.0 legacy hash verification, the defensible conclusion is conditional: these exact candidates behaved as recorded against these exact references. Whether the application's users depend on the older acceptance behavior remains a separate question. If that question is unresolved, preserve the uncertainty in the release decision.

Choose a bounded response to incompatible inputs

A release decision combines the measured matrix with the declared application contract. The table below gives engineering decisions for the stated scope. It does not turn every successful laboratory run into permission to change account behavior.

Evidence or condition

Decision

Required next action

PASS, reviewed within-boundary contract, matching application handling

ACCEPT defined scope

Record the approved boundary and remaining integration limits

Historical acceptance of longer candidates is unknown

HOLD rollout

Establish the historical contract and recovery obligations

Longer candidates were accepted and continued acceptance is required

HOLD unchanged-compatibility claim

Design and approve a transition or controlled recovery path

Candidate exceeds the proposed byte boundary

OVER_LIMIT; no match established

Use reviewed failure handling and available recovery procedures

Admitted candidate encounters malformed verifier material

VERIFIER_ERROR; no match established

Investigate verifier handling and determine authorized recovery

Installation, identity, coverage or worker execution fails

HOLD qualification

Repair the environment or fixture issue and rerun

For the declared contract in this article, the nine-row adapter result is expected: four matches, one mismatch and four over-limit outcomes in each version. Accepting that finite behavior is reasonable once the owner explicitly accepts the contract. Claiming uninterrupted acceptance for formerly truncated candidates contradicts the observed matrix.

A useful approval statement names both evidence and limitation: the specified package comparison passed, the application accepts the declared UTF-8 boundary, and historical behavior has been reviewed for the intended rollout scope. If the owner cannot support that last clause, retain the laboratory PASS while holding the deployment decision. Changing a status label cannot resolve missing account-contract evidence, and a recovery plan should be reviewed before users depend on it.

Correct exception handling before treating the proposed boundary as deployable. No branch should admit a candidate because a verifier call raised an exception. Recovery must establish identity through an approved process; the mere submission of an over-limit password establishes neither ownership nor entitlement to change an account.

Do not silently slice candidate bytes to restore green tests. That preserves ignored suffixes without resolving what the application promises. Do not invent a pre-hash construction to make every input fit: a new representation changes the verification scheme and requires separate design and migration evidence.

Refonte Learning's secure API development practices provide surrounding application context. This bcrypt password migration validation remains narrower: document accepted inputs, preserve failed-check semantics, and approve how incompatible credentials can be recovered.

Plan rollback without pretending password history is reversible

Dependency rollback changes which package processes the next candidate. It can be considered as a compatibility operation with its own security and release review. This article does not recommend deploying a historical security dependency simply because it reproduces an older acceptance result.

The supplied comparison gives specific evidence about 4.3.0-created references verified under both packages. It does not test newly created 5.0.0 hashes under 4.3.0. If an application enrolls or replaces credentials during a rollout, qualify that material before claiming the older deployment can process everything the newer deployment wrote.

Also distinguish the library from the surrounding input policy. Returning to 4.3.0 while retaining the adapter's precheck still produces OVER_LIMIT for long candidates. Reverting the package alone does not restore acceptance that application code now prevents. Conversely, removing a precheck changes policy even if the package version stays fixed.

A rollback plan should therefore identify the package selection, application adapter, enrollment rules and account changes covered by the operation. Record which components can return to a reviewed state and which require a separate decision. The ability to redeploy a previous build says little about whether all credential-lifecycle effects are reversible.

If a proposed rollout temporarily mixes versions, the matrix implies that raw verification of the same long candidate could depend on which version receives it. A consistently applied precheck would instead enforce the proposed boundary across both versions, with the acceptance change already described. Review that transition deliberately. Mixed deployment behavior was not executed in this experiment, and the two-worker comparison should not be presented as a test of request routing or fleet consistency.

No rollback reconstructs an ignored suffix. Automatically hashing stored bcrypt strings again cannot supply the original password either: the stored verifier is different material from the secret a user presents. A recovery or migration process must obtain its required evidence through an approved route.

Keep future password-scheme migration as a separate project

The OWASP Password Storage Cheat Sheet treats bcrypt as a legacy choice and documents both its input limit and pitfalls in pre-hashing compositions. That guidance supports a reviewed migration design rather than an improvised transformation added to an exception handler.

A future scheme needs explicit encoding, algorithm identification, verification routing, identity-proof and reset decisions. Those decisions determine how existing records coexist with newly enrolled credentials and how unsupported inputs are handled. They require evidence beyond this matrix. The present deliverable is a compatibility evidence packet; it can inform that project without becoming an Argon2 implementation guide or endorsing custom hash composition.

Revalidate the matrix when the interpreter or wrapper changes

The bcrypt upgrade regression matrix belongs to a recorded environment. Copying its PASS label into a different deployment loses the relationship between observation and conditions. Revalidation starts by identifying which assumptions changed and what new behavior the original comparison could not observe.

Review these boundaries before reusing the evidence:

Changed assumption

What needs renewed examination

Package version or imported location

Actual library identity and operation outcomes

CPython version, build or platform

Installation, import behavior and execution in that environment

Encoding before bcrypt

Exact bytes presented for the same application input

Normalization or other text transformation

Whether candidate identity has changed before verification

Wrapper or framework

Input policy, API calls and exception translation

Application error handling

Whether failed checks remain failures through the caller

The current script uses one interpreter executable and imports each package in a separate process. Its package version check rejects a reported mismatch, while the recorded module path needs inspection. Separate directories support a controlled comparison; they are not a security sandbox or an independent verification of the downloaded package.

Keep the original run when adding coverage. A useful extension has its own declared expected inputs, operation outcomes and recorded script fingerprint. Editing the expected matrix solely to match whatever a new environment returns destroys the reason for having an oracle. Investigate a divergence before deciding whether it represents an intended contract change.

For the distinct question of adopting a different Python runtime, see Python runtime adoption considerations. No readiness, performance or API/ABI claim from that neighboring topic is needed here. This experiment holds its interpreter constant and supplies no evidence about free-threaded deployment behavior.

Application-level integration remains additional work. The worker does not exercise a web request, database lookup, framework policy or translated public response. A team can retain this package comparison while separately qualifying the path that actually admits candidate credentials in its application.

Assign owners for the compatibility decision

A compatibility report becomes operationally useful when someone owns each inference made from it. The package worker can establish finite observations, but it cannot decide whether a historical customer contract may change. Assign responsibility before a rollout makes those assumptions consequential.

Owner

Evidence to interpret

Decision to record

Authentication owner

Historical enrollment and verification rules; proposed byte boundary

Which candidate behavior the application promises

Security reviewer

Mismatch, exception and over-limit handling; evidence limits

Whether failed checks remain failures and recovery requirements are adequate

Release owner

Interpreter and package identities; complete matrix; report status

Whether the reviewed deployment scope has passed its gates

Support/recovery owner

Approved identity-proof and credential-replacement procedures

How legitimate users encountering incompatible inputs receive help

Keep the handoff concrete. It should reference the run timestamp, platform, Python identity, module paths, package versions, fixture fingerprint, four reference fingerprints, case coverage and outcome matrix. Include the declared contract and a decision rationale alongside the laboratory status. This prevents a later reader from mistaking a reproducible acceptance change for universal account compatibility.

The script keeps raw synthetic hashes in worker exchange and removes them before saving completed worker results. Its normal report contains fingerprints and case metadata, without plaintext candidate fields or raw reference hashes. Preserve that separation when editing the fixture and keep real credentials out of it entirely.

Failure retention has limits: the parent saves completed worker records it received, plus caught failure details, when the output location remains writable. A worker that crashes before emitting JSON does not stream its unfinished rows into the report. A missing or partial evidence packet warrants another qualified run, not an inferred success.

The surrounding responsibilities fit within full-stack application foundations. Here, each owner signs off on a narrower question: what the evidence demonstrates, which account behavior is approved, and who resolves the remaining uncertainty.

Develop security validation skills with Refonte Learning

This exercise develops a practical security-review habit: follow a successful-looking operation until the evidence reaches the application's actual decision. A package installs, a hash is created, a candidate matches, and an account is admitted at different boundaries. Each boundary needs a clear meaning before an upgrade can be judged.

The useful practice is reproducible and specific. Start with independent expected data. Keep verifier material constant across environments. Include a wrong candidate and malformed material. Read exceptions as classified outcomes rather than allowing them to disappear inside a generic success path. Then write a decision whose scope matches the observations.

These habits connect cryptographic interfaces with secure delivery. They help a reviewer explain why a green experiment can still require an operational HOLD, and why controlled credential recovery needs more evidence than a dependency comparison provides. The resulting skill is the ability to distinguish documented behavior, supplied measurements and engineering inference while keeping a release decision reviewable.

For structured study in these broader areas, consider Refonte Learning's Cybersecurity & DevSecOps programme. Its programme specifics, checked on 2026-10-12, state 3 months and 12–14 hours per week. Relevant published curriculum includes cryptography and secure data, OWASP and threat modeling, application-security testing, secure CI/CD, IaC automation and incident response.

Those published subjects explain the programme's relevance to security reasoning. Coverage of Python, pyca/bcrypt, Argon2 or this exact laboratory has not been established. The exercise can serve as an independent practice task: produce a finite evidence packet, explain the bounded acceptance change, and defend an explicit accept, hold or recovery decision without claiming more than the evidence supports.