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):

  • passlib hasn't received commits since 2020
  • It broke when bcrypt 5.0 shipped 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:

SectionMeaning
argon2idAlgorithm used (argon2's recommended variant)
v=19Algorithm version
m=65536Memory cost in KiB (64 MB)
t=3Time cost (3 iterations)
p=4Parallelism (4 threads)
N2xpc2xvOXMxOXNkbGYSalt (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):

  1. It reads the algorithm, parameters, and salt from hashed
  2. It hashes plain with those same parameters and salt
  3. It compares the result with the hash inside hashed
  4. The comparison is constant-time (not vulnerable to timing attacks)
  5. It returns True or False

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:

  1. You lowered every parameter to "make it faster." That reduces security.
  2. You're below OWASP's minimums.
  3. If OWASP changes its recommendations (which happens every 2-3 years), your code never finds out.
  4. 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:

  1. Hashes 3 different passwords
  2. Verifies each password against its own hash (should return True)
  3. 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 pwdlib recommends)
  • pwdlib.recommended() also verifies old bcrypt hashes, so the migration is transparent for existing users

Summary

  • pwdlib is what FastAPI's official docs recommend in 2026, not passlib (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

  1. pwdlib on GitHub — Official repo, source code
  2. pwdlib on PyPI — Installation page and versions
  3. FastAPI — OAuth2 with Password Hashing (official) — Where you'll see pwdlib in official use
  4. argon2-cffi docs — Docs for the argon2 library pwdlib uses internally
  5. PHC string format — The standard hash format you saw in this capsule
  6. OWASP Argon2 recommendations — Officially recommended parameters
  7. 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.