Module 1: Password Security — Introduction
Capsule 03: pwdlib in Python with argon2id
Overview
Time to implement. In this capsule you'll use pwdlib, the library FastAPI's official docs have recommended since 2025-2026 for password hashing. It's the modern successor to passlib (which is abandoned).
You'll learn to create a PasswordHash with recommended(), the .hash() and .verify() functions, what happens internally, how to customize parameters if you need to, and you'll come out with a ready app/core/security.py module you'll reuse for the rest of the guide.
By the end of the capsule you have a working production-grade password hashing system, in under 20 lines of code.
Why pwdlib and not passlib
Before 2024: passlib[bcrypt] was the de facto standard. All of FastAPI's official docs used it.
Today (April 2026):
passlibhasn't received commits since 2020- It broke when
bcrypt 5.0shipped in 2024 - There's an open GitHub discussion in FastAPI's repo asking for migration (#11773)
- FastAPI's official docs already migrated to
pwdlib
pwdlib is actively maintained, supports argon2 and bcrypt, has a cleaner API, and it's what you'll see in new projects.
If you find a tutorial with passlib, it's already outdated. That isn't up for debate.
Installation
pip install "pwdlib[argon2]"
The [argon2] extra installs argon2-cffi (argon2's official library in Python) automatically. Without the extra, pwdlib couldn't use argon2.
Verification:
python -c "from pwdlib import PasswordHash; print(PasswordHash.recommended().hash('test'))"
It should print something like:
$argon2id$v=19$m=65536,t=3,p=4$bGFzaG9mc2FsdA$hash...
If it prints that, you're ready.
The API in 3 lines
pwdlib gives you exactly what you need, nothing more:
from pwdlib import PasswordHash
password_hash = PasswordHash.recommended()
# Hash
hashed = password_hash.hash("my_password_123")
# Verify
is_valid = password_hash.verify("my_password_123", hashed) # True
is_valid = password_hash.verify("wrong_password", hashed) # False
That's it. Three operations: instantiate, hash, verify. The rest of the capsule breaks down what each one does internally.
What PasswordHash.recommended() does
The recommended() method is a convenience constructor that returns an instance configured with the algorithms and parameters the library recommends right now:
- Primary algorithm:
argon2id - Legacy algorithm:
bcrypt(to verify old hashes during migration) - Parameters: the ones OWASP/RFC 9106 recommend for argon2
Why this matters: two years from now, if the community recommends something different, pwdlib will update recommended(). Your code doesn't change, you just update the library and your hashing improves automatically.
If you want to be explicit (not use recommended()):
from pwdlib import PasswordHash
from pwdlib.hashers.argon2 import Argon2Hasher
password_hash = PasswordHash((Argon2Hasher(),))
This is functionally equivalent to the current recommended(), but you're manually tied to argon2. Recommendation: use recommended() unless you have an explicit reason not to.
Anatomy of the hash that gets generated
When you call .hash("my_password"), you get a string like this:
$argon2id$v=19$m=65536,t=3,p=4$N2xpc2xvOXMxOXNkbGY$h7BXQRb...
Each $ separates a section with information:
| Section | Meaning |
|---|---|
argon2id | Algorithm used (argon2's recommended variant) |
v=19 | Algorithm version |
m=65536 | Memory cost in KiB (64 MB) |
t=3 | Time cost (3 iterations) |
p=4 | Parallelism (4 threads) |
N2xpc2xvOXMxOXNkbGY | Salt (base64-encoded) |
h7BXQRb... | The hash itself |
The important part: everything needed to verify the password is in the string. That's why pwdlib.verify() doesn't require you to pass it the salt or the parameters — it reads them from the hash itself.
This is called the PHC string format (Password Hashing Competition format) and it's a standard.
How .verify() works under the hood
When you call password_hash.verify(plain, hashed):
- It reads the algorithm, parameters, and salt from
hashed - It hashes
plainwith those same parameters and salt - It compares the result with the hash inside
hashed - The comparison is constant-time (not vulnerable to timing attacks)
- It returns
TrueorFalse
What it does NOT do:
- It doesn't "decrypt" the hash (that isn't possible)
- It doesn't require you to pass it the salt
- It isn't vulnerable to timing attacks like
==
Your first security.py module
Create app/core/security.py with this:
from pwdlib import PasswordHash
password_hash = PasswordHash.recommended()
def hash_password(password: str) -> str:
return password_hash.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return password_hash.verify(plain_password, hashed_password)
That's all. 8 lines, production-ready. You'll import these functions throughout the rest of the guide every time you need to work with passwords.
Why NOT to do it by hand
You might be tempted to "customize" your setup like this:
# DON'T DO THIS without an explicit reason
import argon2
ph = argon2.PasswordHasher(
time_cost=2, # You lowered the time cost
memory_cost=32768, # You lowered the memory
parallelism=2, # You lowered the parallelism
)
Problems:
- You lowered every parameter to "make it faster." That reduces security.
- You're below OWASP's minimums.
- If OWASP changes its recommendations (which happens every 2-3 years), your code never finds out.
- You're reinventing the wheel.
PasswordHash.recommended() gives you the correct parameters today and updates them when the library updates. Unless you have a benchmark proving that recommended() doesn't work on your specific hardware, use it.
When to customize parameters
There's one case where you do adjust: very limited hardware. For example, if your API runs on a Raspberry Pi or in a container with 256 MB of RAM, argon2's default parameters (which use 64 MB per hash) can be a problem.
from pwdlib import PasswordHash
from pwdlib.hashers.argon2 import Argon2Hasher
# Tuned for limited hardware
password_hash = PasswordHash((
Argon2Hasher(
time_cost=3, # iterations (keep it high)
memory_cost=16384, # 16 MB instead of 64 MB
parallelism=2,
),
))
Rule: if you're going to tune, measure. Hash 100 passwords, verify 100 passwords, look at the time. Aim for each operation to take ~250-500ms. If it takes 50ms, the parameters are too low. If it takes 2 seconds, too high.
Migrating from passlib (if you have old code)
If you inherit code with passlib, the migration is straightforward:
Before (passlib):
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
After (pwdlib):
from pwdlib import PasswordHash
password_hash = PasswordHash.recommended()
def hash_password(password: str) -> str:
return password_hash.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return password_hash.verify(plain, hashed)
Important: pwdlib.recommended() includes a bcrypt verifier for old hashes. That means if you have a DB with hashes in $2b$... format (classic bcrypt), pwdlib can verify them without trouble during the migration period. When a user logs in successfully, you re-hash their password with argon2id and update the record.
Expected output when you run it
from pwdlib import PasswordHash
password_hash = PasswordHash.recommended()
# Hash
hashed = password_hash.hash("hello_world")
print(hashed)
# $argon2id$v=19$m=65536,t=3,p=4$d2x6QnY...$Px8DVk... (something like that)
# Each call generates a DIFFERENT hash (because of the random salt)
hashed2 = password_hash.hash("hello_world")
print(hashed == hashed2)
# False
# But both verify correctly
print(password_hash.verify("hello_world", hashed)) # True
print(password_hash.verify("hello_world", hashed2)) # True
# Wrong password
print(password_hash.verify("something_else", hashed)) # False
The key point that surprises people: two hashes of the same password are different. That's because of the random salt. And .verify() still works on both. The next capsule covers exactly how and why.
Troubleshooting
Problem 1: ImportError: No module named 'argon2'
You installed pwdlib without the [argon2] extra. Reinstall:
pip install "pwdlib[argon2]"
Problem 2: Hashing is too slow (>1 second) on my machine
If the operation takes more than 1 second, your hardware can't handle the defaults. Lower memory_cost:
from pwdlib import PasswordHash
from pwdlib.hashers.argon2 import Argon2Hasher
password_hash = PasswordHash((
Argon2Hasher(memory_cost=32768), # 32 MB instead of 64 MB
))
Measure again. Aim for 250-500ms.
Problem 3: "The hash is too long, it doesn't fit in my VARCHAR(60) column"
argon2 hashes are ~97 characters. If you're coming from bcrypt (60 chars) and your column is VARCHAR(60), you'll have to widen it. Recommendation: use VARCHAR(255) or TEXT for hashed_password to future-proof your schema.
Problem 4: I want to use bcrypt instead of argon2 in a new project
That's valid. bcrypt is still secure in 2026 (we cover it in capsule 05). In pwdlib:
from pwdlib import PasswordHash
from pwdlib.hashers.bcrypt import BcryptHasher
password_hash = PasswordHash((BcryptHasher(),))
But absent a specific reason, recommended() with argon2id is the better default.
Problem 5: verify() always returns False
You're probably passing the arguments backwards. The signature is:
verify(plain_password, hashed_password)
If you do it backwards, it will always fail.
Exercises
Exercise 1: Implement the security.py module
Create the file app/core/security.py with the hash_password and verify_password functions. Write a script that:
- Hashes 3 different passwords
- Verifies each password against its own hash (should return True)
- Verifies each password against ANOTHER one's hash (should return False)
See solution
# app/core/security.py
from pwdlib import PasswordHash
password_hash = PasswordHash.recommended()
def hash_password(password: str) -> str:
return password_hash.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return password_hash.verify(plain_password, hashed_password)
# test_security.py
from app.core.security import hash_password, verify_password
passwords = ["alice123", "bob_secure!", "charlie@2026"]
hashes = [hash_password(p) for p in passwords]
# Correct verification
for p, h in zip(passwords, hashes):
assert verify_password(p, h) is True
print(f"{p} verified against its own hash: OK")
# Cross verification (should fail)
assert verify_password(passwords[0], hashes[1]) is False
assert verify_password(passwords[1], hashes[2]) is False
print("Cross verification fails correctly: OK")
Expected output:
alice123 verified against its own hash: OK
bob_secure! verified against its own hash: OK
charlie@2026 verified against its own hash: OK
Cross verification fails correctly: OK
Exercise 2: Measure the cost
Measure how long hash_password() takes on your machine. Hash 50 passwords and compute the average time.
See solution
import time
from app.core.security import hash_password
start = time.time()
for i in range(50):
hash_password(f"password_{i}")
elapsed = time.time() - start
avg_ms = (elapsed / 50) * 1000
print(f"Average: {avg_ms:.2f} ms per hash")
print(f"Total: {elapsed:.2f} seconds for 50 hashes")
Typical results:
- Modern laptop (M2/M3, Ryzen 7+): 100-300ms per hash
- Cloud server (general purpose): 200-500ms per hash
- Raspberry Pi 4: 1500-3000ms per hash (you need to tune parameters)
If your result is in the 100-500ms range, the defaults are appropriate. If it's outside, consider tuning.
Exercise 3: Inspect a hash
Hash the password "test123" and break the result into its components (algorithm, version, parameters, salt, hash).
See solution
from app.core.security import hash_password
h = hash_password("test123")
print(f"Full hash: {h}\n")
parts = h.split("$")
print(f"Leading empty: '{parts[0]}'")
print(f"Algorithm: {parts[1]}")
print(f"Version: {parts[2]}")
print(f"Parameters: {parts[3]}")
print(f"Salt (b64): {parts[4]}")
print(f"Hash (b64): {parts[5]}")
Output:
Full hash: $argon2id$v=19$m=65536,t=3,p=4$N2xpc2xvOXMxOXNkbGY$h7BXQRb...
Leading empty: ''
Algorithm: argon2id
Version: v=19
Parameters: m=65536,t=3,p=4
Salt (b64): N2xpc2xvOXMxOXNkbGY
Hash (b64): h7BXQRb...
What you learn: everything needed to verify is in the string. The salt lives with the hash. That's why verify() doesn't need extra arguments.
Exercise 4: Spot different hashes of the same password
Hash the password "hello" five times. Are the hashes the same or different? Why? Verify that all 5 are valid for the original password.
See solution
from app.core.security import hash_password, verify_password
password = "hello"
hashes = [hash_password(password) for _ in range(5)]
for i, h in enumerate(hashes):
print(f"Hash {i+1}: {h[:40]}...")
# Are they the same?
print(f"\nAll the same? {len(set(hashes)) == 1}")
# False — they're all different
# But they all validate
for h in hashes:
assert verify_password(password, h) is True
print("All 5 hashes validate correctly: OK")
Why they're different: each call to .hash() generates a new random salt. The salt is included in the final hash, which is why the result is different every time. It's a security property — it means two users with the same password have different hashes in your DB.
Exercise 5: Rewrite this with pwdlib
You have this old code with passlib. Migrate it to pwdlib:
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
class AuthService:
def hash(self, password: str) -> str:
return pwd_context.hash(password)
def verify(self, plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
See solution
from pwdlib import PasswordHash
class AuthService:
def __init__(self):
self.password_hash = PasswordHash.recommended()
def hash(self, password: str) -> str:
return self.password_hash.hash(password)
def verify(self, plain: str, hashed: str) -> bool:
return self.password_hash.verify(plain, hashed)
Notes:
- The API is almost identical in use
- We changed the algorithm from bcrypt to argon2id (what
pwdlibrecommends) pwdlib.recommended()also verifies old bcrypt hashes, so the migration is transparent for existing users
Summary
pwdlibis what FastAPI's official docs recommend in 2026, notpasslib(abandoned).- Installation:
pip install "pwdlib[argon2]". - Minimal API:
PasswordHash.recommended(),.hash(),.verify(). - The
recommended()method gives you the correct parameters and algorithms automatically. Use it unless you have an explicit reason not to. - The hash includes all the info needed to verify (algorithm, parameters, salt). That's why
.verify()doesn't need extra arguments. - Each call to
.hash()produces a different hash of the same password (because of the random salt). It's a security property. - Aim for each operation to take ~250-500ms. If it's outside that range, tune the parameters.
- If you're migrating from
passlib,pwdlib.recommended()can verify old hashes during the transition.
Additional resources
- pwdlib on GitHub — Official repo, source code
- pwdlib on PyPI — Installation page and versions
- FastAPI — OAuth2 with Password Hashing (official) — Where you'll see
pwdlibin official use - argon2-cffi docs — Docs for the argon2 library
pwdlibuses internally - PHC string format — The standard hash format you saw in this capsule
- OWASP Argon2 recommendations — Officially recommended parameters
- GitHub Discussion: passlib vs pwdlib in FastAPI #11773 — The discussion that led to the migration
Next step
Capsule 04: Automatic salt and timing attacks. You'll understand why each hash is different, what exactly a salt is, what a timing attack is, and how .verify() protects you from one.