mrkeyoor.com_
Sun 20 Sept 07:00 UTC
PyPISecurityupdated 20 Sept 2026

bcrypt review

bcrypt is PyCA's Python binding for hashing and checking passwords with the bcrypt algorithm. Its small bytes-only API centers on hashpw(), checkpw(), and gensalt(); kdf() exposes bcrypt_pbkdf for OpenSSH-compatible key derivation. Our Python 3.12 sandbox imported version 5.0.0 successfully and found compiled code plus typing metadata. This release adds Python 3.14, free-threaded 3.14, and Windows ARM support. It also stops accepting passwords over 72 bytes: hashpw() now raises ValueError where older releases discarded the excess bytes.

Verdict

Install bcrypt to preserve bcrypt compatibility or use bcrypt_pbkdf, and plan explicitly for its byte API and 72-byte ceiling. For a new password database with no compatibility constraint, Argon2id is the better default named by this project's maintainers.

We installed it

Lab card: what happened when we installed bcryptScreenshot of bcrypt documentation
Install✓ · 0.2s1 package on disk · 1 MB
Importimport bcrypt in 0.02s · compiled extensions · py.typed · requires Python >=3.8
Known vulns0(pip-audit)

Answers from our run

Does bcrypt install cleanly?

Yes. In a fresh container with an empty cache, pip install bcrypt finished in 0.2s, leaving 1 package and 1 MB on disk. pip-audit reported no known vulnerabilities.

What does bcrypt need to run?

Python >=3.8, and a platform wheel with compiled extensions. In our run import bcrypt succeeded in 0.02s, and the package ships py.typed for type checkers.

bcrypt or argon2-cffi: which should you use?

argon2-cffi: Choose it for new password storage when you want the Argon2id scheme recommended by bcrypt's own README. Install bcrypt to preserve bcrypt compatibility or use bcrypt_pbkdf, and plan explicitly for its byte API and 72-byte ceiling.

When should you not use bcrypt?

You can choose the password scheme for a new application; the bcrypt README points readers to Argon2id or scrypt, both of which avoid bcrypt's fixed 72-byte password ceiling

API stability4/5The public surface remains a short list of functions whose arguments and byte-oriented results have stayed recognizable across releases. Version 5.0.0 still introduced an observable authentication-path break: hashpw() raises ValueError for input above 72 bytes instead of cutting it silently. Teams upgrading from 4.x need a test for existing long-password behavior before rollout.
Docs4/5The repository README shows hashing, verification, work-factor selection, prefix control, bcrypt_pbkdf, platform build prerequisites, and the supported long-password workaround. Its changelog spells out interpreter, wheel, Rust, and behavior changes by release. What is missing is a larger operations guide for tuning costs, limiting concurrent hashes, and migrating stored rows to another scheme.
Maintenance5/5The unarchived PyCA repository was pushed on August 24, 2026 and GitHub reports eight open issues and pull requests. Release 5.0.0 added Python 3.14, free-threaded Python 3.14, and Windows ARM support while raising the minimum Rust compiler for source builds. The unreleased changelog continues with interpreter and compiler-floor changes, which shows active platform maintenance.
Ecosystem4/5PyPI usage is about 50.7 million downloads per week and GitHub shows 1,499 stars. Hashes include their algorithm prefix, cost, and salt, which allows implementations in other languages to verify the same records. The package also works beneath higher-level password libraries. Its reach is narrower for new designs because the maintainers themselves recommend considering Argon2id or scrypt.

Discussed on

  1. hnOkta Bcrypt incident lessons for designing better APIs377 points
  2. hnDon't use bcrypt177 points
  3. hnBcrypt at 25163 points
  4. hnA possible flaw in open-source bcrypt implementations124 points
  5. hnDjango-bcrypt92 points

Use it if

  • Your user table already contains bcrypt hashes that must continue to verify across a Python migration
  • A protocol, audit rule, or neighboring service requires bcrypt output with a compatible 2a or 2b prefix
  • You need bcrypt_pbkdf for an OpenSSH private-key workflow and want the implementation exposed through kdf()
  • You want a narrow password-hash primitive and will own policy, cost upgrades, and scheme migration in application code
Skip it if

Setup reality

Our clean Python 3.12 install of bcrypt 5.0.0 completed in 0.2 seconds. One package occupied 1 MB, and pip-audit reported no known vulnerabilities. The distribution declares two direct dependencies, requires Python 3.8 or newer, includes py.typed, and loads a compiled extension. import bcrypt succeeded in 0.02 seconds. The published license is Apache-2.0.

There are no credentials or configuration files. Mainstream Linux, macOS, Windows, and supported ARM users normally receive a wheel. A less common platform can fall back to a source build, where the README calls for a C compiler and Rust 1.74 or newer. Version 5.0.0 added Windows ARM wheels and Python 3.14 support. The unreleased changelog already drops Python 3.8, so pinning the major alone will not preserve that interpreter forever.

Every password and stored hash crosses the API as bytes. Encode user input with one documented encoding and store the returned 60-byte hash without splitting out a salt. The encoded hash already carries its prefix, cost, and salt. Version 5.0.0 changed the long-password path: hashpw() raises ValueError above 72 bytes. The limit counts encoded bytes, so character length is not a safe proxy for non-ASCII input. Choose either an explicit byte limit or the README's SHA-256 plus base64 prehash convention, then apply it identically during verification.

Hashing is deliberately CPU-heavy. Measure gensalt() costs on production-class hardware, bound concurrent login work, and move calls off an async event loop. bcrypt does not track which stored rows need a higher cost. Parse the cost from a verified hash and replace it during a successful login, or use a password-policy wrapper that handles upgrades and multiple schemes.

Patterns

Create a bcrypt hash hash-password

import bcrypt

plain = "correct horse battery staple".encode("utf-8")
stored = bcrypt.hashpw(plain, bcrypt.gensalt())

Store the complete returned byte string. It already contains the prefix, work factor, and salt needed for verification.

Verify a login password verify-password

import bcrypt

def password_matches(candidate: str, stored: bytes) -> bool:
    return bcrypt.checkpw(candidate.encode("utf-8"), stored)

Use checkpw() rather than hashing with a new salt or comparing manually. A fresh salt intentionally produces a different result.

Store a hash in a text column store-text-hash

import bcrypt

def make_hash(password: str) -> str:
    value = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
    return value.decode("ascii")

def check_hash(password: str, value: str) -> bool:
    return bcrypt.checkpw(password.encode("utf-8"), value.encode("ascii"))

bcrypt output is ASCII-safe. Keep the password encoding fixed at the application boundary so signup and login produce the same bytes.

Benchmark candidate work factors set-work-factor

import bcrypt
import time

for cost in (11, 12, 13, 14):
    started = time.perf_counter()
    bcrypt.hashpw(b"timing sample", bcrypt.gensalt(rounds=cost))
    print(cost, time.perf_counter() - started)

One step doubles the work. Run the benchmark on production-class hardware and include your peak login concurrency when choosing a cost.

Reject input beyond bcrypt's limit enforce-byte-limit

def bcrypt_bytes(password: str) -> bytes:
    value = password.encode("utf-8")
    if len(value) > 72:
        raise ValueError("password exceeds bcrypt's 72-byte limit")
    return value

Version 5.0.0 raises ValueError above 72 bytes. Check bytes rather than characters because UTF-8 characters have different encoded lengths.

Prehash without introducing NUL bytes prehash-long-password

import base64
import hashlib

def bcrypt_input(password: str) -> bytes:
    digest = hashlib.sha256(password.encode("utf-8")).digest()
    return base64.b64encode(digest)

This follows the README's workaround for long passwords. Record the convention and use it for every old and new verification; changing it invalidates stored hashes.

Replace a low-cost hash after verification upgrade-cost-on-login

import bcrypt

TARGET_COST = 13

def verify_and_upgrade(plain: bytes, stored: bytes):
    if not bcrypt.checkpw(plain, stored):
        return False, stored
    current_cost = int(stored.split(b"$")[2])
    upgraded = bcrypt.hashpw(plain, bcrypt.gensalt(TARGET_COST)) if current_cost < TARGET_COST else stored
    return True, upgraded

Write the upgraded value only after successful verification. Login is the normal point where the application briefly has the plaintext again.

Spend hashing work for an unknown account hide-user-existence

import bcrypt

DUMMY_HASH = bcrypt.hashpw(b"unused account", bcrypt.gensalt())

def authenticate(candidate: bytes, stored: bytes | None) -> bool:
    matched = bcrypt.checkpw(candidate, stored or DUMMY_HASH)
    return stored is not None and matched

Create the dummy once when the process starts. Generating it inside the request adds avoidable work and makes timing less predictable.

Run password hashing outside the event loop hash-with-asyncio

import asyncio
import bcrypt

async def make_hash(plain: bytes) -> bytes:
    salt = bcrypt.gensalt()
    return await asyncio.to_thread(bcrypt.hashpw, plain, salt)

bcrypt work is intentionally expensive. A worker thread keeps unrelated async sockets and timers moving while the hash runs.

Treat malformed stored data as a failed check handle-invalid-hash

import bcrypt

def safe_check(candidate: bytes, stored: bytes) -> bool:
    try:
        return bcrypt.checkpw(candidate, stored)
    except ValueError:
        return False

Invalid bcrypt data raises ValueError. Log corruption through a separate path without returning parsing details to the caller.

Generate a 2a hash for an older peer write-legacy-prefix

import bcrypt

salt = bcrypt.gensalt(prefix=b"2a")
stored = bcrypt.hashpw(password_bytes, salt)

The default prefix is 2b. Request 2a only when a system that consumes newly written hashes cannot read 2b output.

Use bcrypt_pbkdf for key derivation derive-openssh-key

import bcrypt
import os

key = bcrypt.kdf(
    password=passphrase_bytes,
    salt=os.urandom(16),
    desired_key_bytes=32,
    rounds=100,
)

kdf() exposes bcrypt_pbkdf for formats such as OpenSSH private keys. Its rounds value is not the logarithmic cost accepted by gensalt().

Alternatives

PackageRegistryPick it when
argon2-cffiPyPIChoose it for new password storage when you want the Argon2id scheme recommended by bcrypt's own README
pwdlibPyPIChoose it when an application needs a current password-policy wrapper with verification and rehash checks
passlibPyPIChoose it when a legacy database contains several password-hash schemes that one verifier must recognize

More security guides

cryptography · pyjwt · jose · requests-oauthlib · oauthlib · dompurify · the whole shelf →

How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.