Modern password hashing for Python. A drop-in replacement for passlib, which has been unmaintained since 2020 and is broken on Python 3.13+.
- passlib is dead. Last release was October 2020. It crashes on Python 3.13+ due to the removed
cryptmodule. - Zero required dependencies. Pure Python implementations for all hashlib-based schemes. Optional deps for argon2 and bcrypt.
- Python 3.10+ only. No Python 2 baggage. Full type hints with
py.typedmarker. - passlib-compatible API.
CryptContext, hash identification, andverify_and_update()all work the same way. - Secure defaults. Argon2id as the default scheme with safe parameter defaults and timing-safe comparisons.
pip install hashwardWith argon2 support (recommended):
pip install hashward[argon2]With bcrypt support:
pip install hashward[bcrypt]With everything:
pip install hashward[all]import hashward
# Hash a password (uses argon2id by default)
hashed = hashward.hash("my secret password")
# '$argon2id$v=19$m=65536,t=2,p=2$...'
# Verify a password
hashward.verify("my secret password", hashed) # True
hashward.verify("wrong password", hashed) # False
# Use a specific scheme
hashed = hashward.hash("my secret password", scheme="bcrypt")
# '$2b$12$...'from hashward import CryptContext
# Create a context with your preferred schemes
ctx = CryptContext(
schemes=["argon2", "bcrypt", "pbkdf2_sha256"],
default="argon2",
deprecated=["pbkdf2_sha256"],
argon2__time_cost=3,
argon2__memory_cost=65536,
)
# Hash and verify
hashed = ctx.hash("password")
assert ctx.verify("password", hashed)
# Automatic scheme identification
ctx.identify(hashed) # "argon2"
# Check if a hash needs upgrading (deprecated scheme or outdated params)
ctx.needs_update(hashed) # False (it's current)
# Verify and get a new hash if the old one needs upgrading
valid, new_hash = ctx.verify_and_update("password", old_bcrypt_hash)
if valid and new_hash:
# Save new_hash to your database — it's been upgraded to argon2
save_to_db(new_hash)from hashward import identify
identify("$argon2id$v=19$m=65536,t=2,p=2$...") # "argon2"
identify("$2b$12$...") # "bcrypt"
identify("$6$rounds=656000$...") # "sha512_crypt"
identify("$1$...") # "md5_crypt"
identify("pbkdf2_sha256$600000$...") # "django_pbkdf2_sha256"from hashward import CryptContext
# Load from INI-format string
config = """
[hashward]
schemes = argon2, bcrypt, pbkdf2_sha256
default = argon2
deprecated = pbkdf2_sha256
argon2__time_cost = 3
argon2__memory_cost = 65536
"""
ctx = CryptContext.from_string(config)
# Serialize back
print(ctx.to_string())| Scheme | Backend | Notes |
|---|---|---|
argon2 |
argon2-cffi | Default. Argon2id, memory-hard. Requires pip install hashward[argon2]. |
bcrypt |
bcrypt | Industry standard. Requires pip install hashward[bcrypt]. |
bcrypt_sha256 |
bcrypt | Bcrypt with SHA-256 pre-hash (no 72-byte limit). |
scrypt |
hashlib.scrypt (stdlib) |
Memory-hard. No extra dependencies. |
pbkdf2_sha256 |
hashlib.pbkdf2_hmac (stdlib) |
NIST-approved. No extra dependencies. |
pbkdf2_sha512 |
hashlib.pbkdf2_hmac (stdlib) |
NIST-approved. No extra dependencies. |
| Scheme | Notes |
|---|---|
sha512_crypt |
Common in /etc/shadow. Pure Python, no crypt module needed. |
sha256_crypt |
Common in /etc/shadow. Pure Python, no crypt module needed. |
md5_crypt |
Insecure. Always reports needs_update. |
des_crypt |
Insecure. Always reports needs_update. |
| Scheme | Notes |
|---|---|
django_pbkdf2_sha256 |
Django's default hasher format. |
django_bcrypt |
Django bcrypt format. |
django_bcrypt_sha256 |
Django bcrypt+SHA-256 format. |
django_argon2 |
Django argon2 format. |
django_scrypt |
Django scrypt format. |
hashward provides a compatibility layer for gradual migration.
# Before (passlib):
from passlib.context import CryptContext
# After (hashward — direct):
from hashward import CryptContext
# Or use the compat shim for minimal changes:
from hashward.compat.passlib import CryptContext# passlib config:
ctx = CryptContext(schemes=["argon2", "bcrypt", "pbkdf2_sha256"])
# hashward config (identical API):
ctx = CryptContext(schemes=["argon2", "bcrypt", "pbkdf2_sha256"])hashward can verify hashes generated by passlib. All passlib hash formats are recognized, including bcrypt_sha256 v1 and v2 formats.
from hashward import CryptContext
ctx = CryptContext(schemes=["argon2", "bcrypt", "pbkdf2_sha256"], deprecated=["pbkdf2_sha256"])
# Verify a passlib-generated hash
old_hash = "$pbkdf2-sha256$29000$..." # generated by passlib
valid = ctx.verify("password", old_hash)
# Automatically upgrade old hashes
valid, new_hash = ctx.verify_and_update("password", old_hash)
if valid and new_hash:
# new_hash is argon2id — save it
save_to_db(new_hash)Hash a password using the specified scheme (default: argon2).
Verify a password against a hash string. Automatically identifies the scheme.
Detect the hashing scheme from a hash string. Returns the scheme name or None.
Create a policy manager for password hashing.
schemes— list of allowed scheme namesdefault— scheme to use for new hashesdeprecated— list of schemes that triggerneeds_update()min_verify_time— minimum wall-clock seconds forverify()(default:0, disabled). Useful for preventing timing-based user enumeration.truncate_error— ifTrue, raisePasswordValueErrorwhen a bcrypt password exceeds 72 bytes instead of silently truncating (default:False)**settings— per-scheme settings usingscheme__param=valuesyntax
Hash a password. Uses the default scheme unless overridden.
Verify a password against a hash. Returns False for unrecognized hashes.
Identify the scheme of a hash string. Only returns schemes configured in this context.
Check if a hash needs re-hashing (deprecated scheme or outdated parameters).
Verify and return (valid, new_hash). new_hash is None if no update is needed.
Return a new CryptContext with overridden settings.
Alias for ctx.using().
Serialize to / deserialize from INI-format configuration strings.
- Default scheme is argon2id with memory-hard parameters (64 MiB, 2 iterations, 2 threads).
- Timing-safe comparisons via
hmac.compare_digestfor all hash verification. - No
cryptmodule dependency. All legacy schemes (SHA-crypt, MD5-crypt, DES-crypt) use pure Python implementations. - DES-crypt always reports
needs_update. Any existing DES-crypt hash will trigger automatic re-hashing on the nextverify_and_update()call. Do not use it for new hashes. - bcrypt 72-byte limit is handled: passwords are silently truncated. Use
bcrypt_sha256ortruncate_error=Truein CryptContext for explicit control.
# Clone and set up
git clone https://github.com/agentine/hashward.git
cd hashward
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
# Run tests
pytest tests/ -v
# Run benchmarks
python benchmarks/bench_hashing.pyMIT