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.
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.
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
- You are choosing freely for a new project: the project's own README says you should really use argon2id or scrypt instead, and argon2-cffi is no harder to use
- You are upgrading from bcrypt 4.x with users who have long passwords: 5.0.0 turned the old silent 72-byte truncation into a ValueError, so a password that used to log in fine can now raise on your login route
- You want a password layer rather than one algorithm: passlib and pwdlib handle multiple schemes, detect which one a stored hash uses, and re-hash on login, all of which you hand-write on top of this library
- You cannot build native code and no wheel matches your platform: there is no pure-Python fallback, and building needs a C compiler plus a Rust toolchain (Rust 1.74 or newer for 5.0.0)
- You dislike bytes-only APIs: every call encodes and decodes at the boundary, and forgetting once gives you a TypeError in your authentication path
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 includedThe 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 12There 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
| Package | Registry | Pick it when |
|---|---|---|
| argon2-cffi | PyPI | New code with no legacy hashes: Argon2id is what this project's own README points you toward |
| pwdlib | PyPI | You want a small modern wrapper that picks a scheme, verifies any of them, and tells you when to re-hash |
| passlib | PyPI | You are migrating a legacy user table with several hash formats mixed together and need broad scheme support |