mrkeyoor.com_
Thu 06 Aug 00:58 UTC
PyPISecurityupdated 05 Aug 2026

bcrypt

bcrypt is the Python Cryptographic Authority's binding for the bcrypt password hashing algorithm, implemented in Rust. The whole library is four functions. You call gensalt() to get a salt that encodes a work factor, hashpw() to turn a password into a 60-byte hash with that salt baked in, and checkpw() to test a login attempt against a stored hash. There is also kdf(), which implements bcrypt_pbkdf for reading OpenSSH's newer encrypted private key format. Everything takes and returns bytes, never str. The work factor is logarithmic: each extra round doubles the time an attacker needs per guess, which is the entire point of using it instead of a plain hash.

Verdict

A clean, well-maintained binding for an algorithm its own maintainers describe as merely acceptable. Keep it for existing hashes and OpenSSH key work; reach for argon2-cffi or pwdlib when you get to pick.

API stability4/5hashpw, checkpw, gensalt, and kdf have looked the same for years, but 5.0.0 changed real behaviour by raising ValueError past 72 bytes instead of truncating, which can break a working login flow on upgrade.
Docs3/5One README covers install per distro, usage, the work factor, prefixes, and the 72-byte workaround, and it honestly names better algorithms. There is no docs site, no API reference, and no guidance on cost tuning or rehashing.
Maintenance5/5Maintained by the pyca group behind cryptography: pushed August 2026, only 5 issues open (8 counting PRs), and releases track new CPython versions including free-threaded 3.14.
Ecosystem4/5Roughly 50M weekly downloads and it is the backend Django, passlib, and pwdlib reach for when you ask for bcrypt; the caveat is that new projects are steadily moving to Argon2.

Use it if

  • You already have a table of bcrypt hashes, from another language or an older Python stack, and need to keep verifying them
  • Your compliance checklist or auditor names bcrypt specifically, which is still common in older policy documents
  • You want the smallest reasonable password-hashing dependency: four functions, a Rust core maintained by the same group that maintains cryptography, and prebuilt wheels for the platforms you likely deploy on
  • You need bcrypt_pbkdf to read or write OpenSSH private keys, which kdf() gives you and most password libraries do not
Skip it if

Setup reality

pip install bcrypt pulls a wheel on mainstream Linux (glibc and musl), macOS, and Windows including ARM, so most people are done in one command. Anywhere else it compiles, and that means a C compiler plus Rust: the README lists build-essential and cargo on Debian and Ubuntu, gcc and cargo on Fedora, and musl-dev, gcc and cargo on Alpine, which is the usual surprise inside a slim Docker image. Package metadata declares Python 3.8 and up, the README says 3.9 and up, and the unreleased changelog drops 3.8 and bumps the Rust minimum to 1.85. Two behaviours will bite you in code, not in install: everything is bytes, so str arguments raise TypeError, and since 5.0.0 a password over 72 bytes raises ValueError where 4.x silently cut it short.

Patterns

Hash a password for storagehash-password

import bcrypt

password = b"correct horse battery staple"
hashed = bcrypt.hashpw(password, bcrypt.gensalt())
# b'$2b$12$...'  60 bytes, salt included

The salt is embedded in the output, so there is no second column to store. gensalt() defaults to 12 rounds in current versions; older tutorials showing 10 or 4 are out of date.

Check a login attemptverify-password

import bcrypt

if bcrypt.checkpw(attempt.encode("utf-8"), user.password_hash):
    login(user)
else:
    raise Unauthorized()

checkpw reads the algorithm, cost, and salt out of the stored hash for you. Never compare hashes with ==; checkpw does a constant-time comparison internally.

Convert between str and bytes at the boundaryencode-str-inputs

import bcrypt

def hash_password(plain: str) -> str:
    return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt()).decode("ascii")

def verify(plain: str, stored: str) -> bool:
    return bcrypt.checkpw(plain.encode("utf-8"), stored.encode("ascii"))

Passing a str straight into hashpw raises TypeError. Always encode as UTF-8, and always with the same encoding on both sides, or the same password produces a different byte string and fails to verify.

Pick a cost factor on purposeadjust-work-factor

import bcrypt, time

for rounds in (10, 12, 13, 14):
    start = time.perf_counter()
    bcrypt.hashpw(b"benchmark", bcrypt.gensalt(rounds))
    print(rounds, round(time.perf_counter() - start, 3), "s")

Rounds are logarithmic: 14 is four times the work of 12. Benchmark on the hardware you actually deploy to and choose the highest value your login latency budget allows.

Deal with the 72-byte limithandle-long-passwords

import base64, bcrypt, hashlib

def prehash(password: bytes) -> bytes:
    return base64.b64encode(hashlib.sha256(password).digest())

hashed = bcrypt.hashpw(prehash(password), bcrypt.gensalt())

This is the README's own workaround, and base64 matters: a raw SHA-256 digest can contain a NUL byte, which bcrypt treats as the end of the string. Apply the same prehash on verify or nobody logs in.

Catch the 5.0 ValueError before it reaches usersreject-long-passwords

import bcrypt

if len(password.encode("utf-8")) > 72:
    raise ValidationError("Password must be 72 bytes or fewer")
hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())

The limit is bytes, not characters, so emoji and non-Latin scripts hit it around 18 to 24 characters. Validate at signup, or existing users whose 4.x hash came from a truncated password will start seeing errors after you upgrade.

Find out what cost a stored hash usedread-cost-from-hash

stored = b"$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyDA1nWyuvB3Ee"
prefix, cost = stored.split(b"$")[1:3]
print(prefix.decode(), int(cost))  # 2b 12

There is no helper for this, so you parse the string. Useful for auditing how much of your user table still sits at an outdated cost.

Upgrade a hash when the user signs inrehash-on-login

TARGET_ROUNDS = 13

if bcrypt.checkpw(plain, user.password_hash):
    cost = int(user.password_hash.split(b"$")[2])
    if cost < TARGET_ROUNDS:
        user.password_hash = bcrypt.hashpw(plain, bcrypt.gensalt(TARGET_ROUNDS))
        save(user)

Login is the only moment you hold the plaintext, so it is the only chance to re-hash. The same hook is how you migrate a table off bcrypt entirely and onto Argon2.

Keep response time the same for unknown usersavoid-user-enumeration

DUMMY = bcrypt.hashpw(b"unused", bcrypt.gensalt())  # compute once at startup

user = users.get(email)
target = user.password_hash if user else DUMMY
ok = bcrypt.checkpw(attempt, target)
if not user or not ok:
    raise Unauthorized()

Without the dummy hash, unknown emails return in microseconds while real ones take hundreds of milliseconds, which is enough to enumerate your user list from the outside.

Keep hashing off the event loopoffload-in-async

import asyncio, bcrypt

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

At cost 12 a single hash blocks for a noticeable fraction of a second, which stalls every other request in an async server. The Rust code releases the GIL, so a thread genuinely helps here.

Produce hashes another implementation can readlegacy-prefix-compatibility

import bcrypt

salt = bcrypt.gensalt(prefix=b"2a")  # default is b"2b"
hashed = bcrypt.hashpw(password, salt)

Only set 2a when something on the other side cannot read 2b hashes. Verification handles all the prefixes it supports automatically, so this is about writing, not reading; $2y$ is still accepted by hashpw but deprecated.

Derive a key with bcrypt_pbkdfopenssh-key-derivation

import bcrypt, os

key = bcrypt.kdf(
    password=b"correct horse",
    salt=os.urandom(16),
    desired_key_bytes=32,
    rounds=100,
)

This is the KDF used by OpenSSH's newer private key format, not a password-storage function. Rounds here mean literal iterations, so 100 is a normal value and is not comparable to gensalt's logarithmic cost.

Alternatives

PackageRegistryPick it when
argon2-cffiPyPINew code with no legacy hashes: Argon2id is what this project's own README points you toward
pwdlibPyPIYou want a small modern wrapper that picks a scheme, verifies any of them, and tells you when to re-hash
passlibPyPIYou are migrating a legacy user table with several hash formats mixed together and need broad scheme support