mrkeyoor.com_
Fri 07 Aug 20:52 UTC
PyPISecurityupdated 07 Aug 2026

passlib

passlib is a password hashing framework rather than a single hash function. It wraps over thirty schemes behind one interface, from modern ones such as argon2, bcrypt and pbkdf2_sha256 down to whatever is sitting in an old /etc/shadow file or a 2007 phpBB database, and each handler exposes the same hash(), verify() and identify() calls. The piece people actually come for is CryptContext: you declare a list of schemes in preference order, mark the old ones deprecated, and then verify_and_update() checks a password against whichever scheme the stored hash uses and hands you back a freshly rehashed value when the old one is out of date. That is how you migrate a user table off md5 without asking anyone to reset their password. It also ships passlib.totp for time-based one-time passwords with encrypted secret storage. It is capable software and it is also frozen: 1.7.4 was published on 2020-10-08 and nothing has been released since.

Verdict

Still the best tool in Python for reading and migrating legacy password hashes, and verify_and_update() alone can justify keeping it in an old codebase. Do not reach for it in anything new: a library whose last release predates both bcrypt 5 and the removal of the crypt module is not where your password storage should live.

API stability5/5Perfectly stable, for the reason you would rather it were not. The CryptContext and handler APIs have not changed since 1.7.0 in 2016 and cannot change, because nothing has been released since 1.7.4 on 2020-10-08. Code written a decade ago runs unmodified.
Docs4/5passlib.readthedocs.io is unusually thorough for a library this size: a narrative introduction, a page per handler documenting the format string and every keyword, a whole chapter on hash migration, and honest discussion of relative scheme strength. What it cannot cover is everything that broke after 2020, so the bcrypt 5 failure, the __about__ warning and the Python 3.13 crypt removal are absent and you find them in your own traceback.
Maintenance1/5Effectively abandoned as a shipping product. The last release is 1.7.4 from 2020-10-08, almost six years ago. The Heptapod project shows a last activity timestamp of 2026-04-24, so someone is still reading the tracker, but the two known breakages against current bcrypt and current Python have sat unreleased. For a password hashing library that is the most serious mark on this page.
Ecosystem4/5Roughly 9.3M weekly downloads and still embedded in a generation of Django, Flask and FastAPI tutorials, so it is present in an enormous number of production stacks. The direction of travel is away from it: FastAPI's own documentation moved to pwdlib, and new projects reach for argon2-cffi or bcrypt directly.

Use it if

  • You inherited stored hashes in a format nobody wants to touch: /etc/shadow entries, old Django or Drupal or phpBB tables, MySQL's OLD_PASSWORD, LDAP RFC-2307 strings. passlib reads them all and is the shortest path to verifying a login against them
  • You need to migrate hashes in place. CryptContext with deprecated='auto' plus verify_and_update() gives you the whole upgrade story in one call: correct password, old scheme detected, new hash returned for you to store
  • You need to identify a hash you were handed. context.identify(hash) tells you which scheme produced it, which is the first thing you want during a security audit or a database import
  • You want TOTP with the storage problem solved. passlib.totp with TOTP.using(secrets={...}) encrypts the shared secret at rest with an application key and handles key rotation, which is more than most standalone TOTP libraries offer
Skip it if

Setup reality

pip install passlib is instant and pure Python, but the useful configurations need extras and each has a catch. passlib[bcrypt] installs the bcrypt package, and you must pin it below 5.0 or the backend is dead on arrival with a ValueError from passlib's own compatibility probe; even bcrypt 4.x logs an AttributeError traceback about a missing __about__ attribute on every process start. passlib[argon2] pulls argon2-cffi, which needs a wheel or a C toolchain, and this is the combination that still works cleanly on Python 3.13. passlib[totp] needs cryptography for secret encryption. There is no requires_python declared on the wheel, so pip will happily install it on runtimes it was never tested against. Nothing warns you that on Python 3.13 the stdlib crypt module is gone and the OS-backed schemes have silently switched to pure-Python fallbacks. If you use bcrypt, remember that the algorithm itself truncates at 72 bytes and modern bcrypt raises rather than truncating, so long passphrases and multi-byte characters need a pre-hash step you write yourself. Pin every one of these versions in your lockfile, because the failure modes arrive through your transitive dependencies rather than through passlib.

Patterns

Hash and verify with an explicit work factorhash-and-verify

from passlib.hash import pbkdf2_sha256

hasher = pbkdf2_sha256.using(rounds=600_000)
stored = hasher.hash('correct horse battery staple')

print(stored[:30])   # $pbkdf2-sha256$600000$...
print(pbkdf2_sha256.verify('correct horse battery staple', stored))  # True

Always go through .using(rounds=...). pbkdf2_sha256.default_rounds is 29000 in 1.7.4 and has not been revised since, so the bare pbkdf2_sha256.hash(pw) you see in most tutorials produces a work factor set for hardware from another decade.

Declare a context with a preferred scheme and deprecated onescrypt-context

from passlib.context import CryptContext

pwd = CryptContext(
    schemes=['argon2', 'pbkdf2_sha256', 'sha512_crypt', 'des_crypt'],
    deprecated='auto',
    argon2__memory_cost=65536,
    argon2__time_cost=3,
)

hashed = pwd.hash('secret')   # uses argon2, the first scheme

The first scheme in the list is what new hashes use; everything after it is accepted on verify only. deprecated='auto' marks every scheme except the first as needing an upgrade, and the double-underscore keywords are how you pass per-scheme settings.

Upgrade a hash during a successful loginverify-and-migrate

ok, new_hash = pwd.verify_and_update(submitted_password, user.password_hash)
if not ok:
    raise AuthError()
if new_hash:
    user.password_hash = new_hash
    session.commit()

This is the reason passlib is still in codebases. new_hash is None when the stored hash is already current, so the branch stays cheap. The rehash uses the plaintext you already have in hand, which is the only moment you get to do it without a password reset email.

Work out which scheme produced a stored hashidentify-unknown-hash

legacy = 'A7Tt6IjNRAIL.'
print(pwd.identify(legacy))          # 'des_crypt'
print(pwd.needs_update(legacy))      # True

from passlib.registry import list_crypt_handlers
print(len(list_crypt_handlers()))    # 77 registered names

identify() returns None when no configured scheme recognizes the string, which is also what a corrupted or truncated column looks like, so audit both cases. It only checks schemes in the context, not all 77 handlers passlib knows about.

Use argon2id with parameters you choseargon2-configuration

from passlib.hash import argon2

print(argon2.default_rounds, argon2.memory_cost, argon2.parallelism)
# 3 65536 4

h = argon2.using(memory_cost=131072, time_cost=4, parallelism=2).hash('secret')
print(h[:34])  # $argon2id$v=19$m=131072,t=4,p=2$

This needs argon2-cffi installed via passlib[argon2] and is the backend combination that still behaves correctly on Python 3.13. The parameters are encoded in the hash string, so old hashes keep verifying with their original settings after you raise the defaults.

Keep the bcrypt backend working at allbcrypt-version-pin

# pyproject.toml / requirements.txt
#   passlib[bcrypt]==1.7.4
#   bcrypt<5

from passlib.hash import bcrypt
print(bcrypt.using(rounds=12).hash('secret')[:15])  # $2b$12$...

On bcrypt 5.0 this raises ValueError('password cannot be longer than 72 bytes') for any input, because passlib probes for an old wraparound bug by hashing a test string longer than 72 bytes at backend load. On bcrypt 4.x it works but logs a trapped AttributeError about bcrypt.__about__ every time the backend loads.

Handle passphrases longer than bcrypt's 72-byte limitlong-password-prehash

import base64, hashlib
from passlib.hash import bcrypt

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

stored = bcrypt.using(rounds=12).hash(prehash(password))
bcrypt.verify(prehash(candidate), stored)

bcrypt truncates at 72 bytes, and multi-byte characters hit that limit far sooner than 72 visible characters. Base64 the digest rather than passing raw bytes: that keeps the input to 44 printable ASCII bytes and sidesteps the historical disagreements between bcrypt implementations over embedded null bytes. Whichever pre-hash you pick, it has to be applied identically on hash and on verify forever.

Avoid leaking which usernames existconstant-time-unknown-user

user = db.get_user(username)
if user is None:
    pwd.dummy_verify()      # burns the same time as a real verify
    raise AuthError()

if not pwd.verify(password, user.password_hash):
    raise AuthError()

Without dummy_verify() an unknown username returns in microseconds while a real one takes the full hash time, and that difference is measurable over a network. It returns False and never raises, so it drops straight into the miss branch.

Verify a hash from /etc/shadowlegacy-shadow-hashes

from passlib.hash import sha512_crypt
import passlib.utils

print(passlib.utils.has_crypt)   # False on Python 3.13+
print(sha512_crypt.verify('secret', shadow_entry))

On Python 3.12 and earlier this calls the OS crypt(3). On 3.13 the stdlib crypt module is gone, has_crypt becomes False, and passlib silently uses its own pure-Python implementation. Results match, speed does not, and no warning is emitted.

Load hashing policy from a config filecontext-from-config

pwd = CryptContext.from_string("""
[passlib]
schemes = argon2, pbkdf2_sha256, sha512_crypt
deprecated = auto
argon2__memory_cost = 65536
pbkdf2_sha256__rounds = 600000
""")

pwd.update(argon2__time_cost=4)   # bump without a redeploy of code

Keeping the policy in config lets you raise cost parameters without touching application code, and from_path() reads the same ini format from disk. Changing settings does not invalidate existing hashes, since each stored string carries the parameters it was created with.

Issue and check TOTP codes with encrypted secretstotp-secrets

from passlib.totp import TOTP, generate_secret

# generate_secret() once, store it in your app config, never in the database
Factory = TOTP.using(secrets={'1': APP_TOTP_KEY})

totp = Factory(new=True)
uri = totp.to_uri(issuer='example.com', label='ada@example.com')
blob = totp.to_json()    # {"enckey": ...} encrypted at rest

TOTP.using(secrets=...) encrypts the shared secret with your application key before it reaches the database, and the numeric tag lets you rotate keys while old rows still decrypt. This path needs the cryptography package from passlib[totp].

Validate a submitted TOTP tokentotp-verify

from passlib.exc import TokenError

totp = Factory.from_json(user.totp_blob)
try:
    match = totp.match(submitted_token, last_counter=user.totp_last_counter)
except TokenError as exc:
    raise AuthError(str(exc))

user.totp_last_counter = match.counter   # blocks replay of the same code

match() raises rather than returning False: a wrong code gives InvalidTokenError and a malformed one gives MalformedTokenError, both subclasses of TokenError. Persisting match.counter is what stops an attacker replaying a code that is still inside its window.

Alternatives

PackageRegistryPick it when
pwdlibPyPIYou want a small maintained replacement with the same hash/verify/needs-update shape and only modern schemes
argon2-cffiPyPIYou need argon2id and nothing else, with sensible current defaults and active maintenance
bcryptPyPIPolicy or an existing hash column requires bcrypt and you would rather call it directly than through a wrapper that breaks on version 5
cryptographyPyPIYou need PBKDF2, Scrypt or key derivation as part of a wider cryptographic stack rather than password storage alone