How to Hash Passwords Correctly in Python With bcrypt and Argon2
Why fast hashes like SHA-256 fail at password storage, and how to use bcrypt and Argon2id correctly, including a real bug hiding inside the standard fix for bcrypt's 72-byte limit.
If you have ever written a signup form, you have faced this question: how do you store a user’s password so that if your database ever leaks, an attacker still cannot recover it? The honest answer is that you cannot make it impossible, but you can make it so expensive and so slow that cracking even one password stops being worth an attacker’s time. That is what password hashing means: turning a password into a fixed-length value using a one-way function that is deliberately hard to reverse, hard to speed up, and unique per password even when two users pick the same one.
Table Of Content
- What You Will Build
- Prerequisites
- Step 1: Why hashlib.sha256() Alone Is Not Password Hashing
- Step 2: A Salt Fixes One Problem, Not the Real One
- Step 3: Slow Hashing With scrypt (Standard Library)
- Step 4: bcrypt, the Industry-Standard Slow Hash
- Step 5: The bcrypt 72-Byte Limit (and Why the Obvious Fix Is a Bug)
- Step 6: Argon2id, OWASP’s Current First Choice
- Step 7: Putting It Together: Signup, Login, and Migrating Old Hashes
- Verify Your Work
- Common Mistakes to Avoid
- Confirm It All Works End to End
This is different from encryption. Encryption is reversible: if you have the key, you can get the original data back. Hashing is one-way by design: there is no key that turns a password hash back into the plaintext password. A login system should never be able to email you your old password, because it should never have stored anything it could recover it from.
In this tutorial you will build every piece of this yourself, in a real Python environment, and watch it work (and sometimes fail) with your own eyes. You will hash passwords the wrong way first, actually crack them, then fix the mistakes one at a time until you land on the approach OWASP currently recommends. Along the way you will hit two real, reproducible bugs: a memory-limit error in Python’s own standard library, and a genuine security bug hiding inside the “obvious” fix for one of bcrypt’s quirks. Both are demonstrated with real code you can run, not described secondhand.
What You Will Build
By the end of this tutorial you will have a small, working auth.py module with a UserStore class that supports signing up new users, logging in existing ones, and automatically upgrading anyone still on an old, weaker hash the moment they log in successfully, with zero forced password resets. You will also have a full pytest suite that verifies all of it, including the tricky edge cases.
Prerequisites
- Python 3.10 or later (this tutorial was built and tested on Python 3.13).
- Comfort with basic Python: functions, dictionaries, exceptions. No prior cryptography knowledge is assumed; every term is defined before it is used.
- A terminal and the ability to create a virtual environment and install packages with pip.
Set up an isolated environment and install the two libraries this tutorial relies on:
python -m venv venv
venv\Scripts\activate # on Windows
# source venv/bin/activate # on macOS/Linux
pip install bcrypt argon2-cffi pytest
This tutorial used bcrypt 5.0.0 and argon2-cffi 25.1.0. Both ship prebuilt wheels for Windows, macOS, and Linux, so this install does not need a C compiler.
Step 1: Why hashlib.sha256() Alone Is Not Password Hashing
The most common mistake is reaching for a general-purpose hash function like SHA-256 because it is already in Python’s standard library and “hashing” is right there in the name. Let’s build that naive version first, and then attack it, so the failure is something you have seen rather than something you have been told about.
# naive_hash.py
import hashlib
import time
def naive_hash(password: str) -> str:
return hashlib.sha256(password.encode("utf-8")).hexdigest()
users = {
"alice": naive_hash("sunshine1"),
"bob": naive_hash("correcthorse"),
"carol": naive_hash("sunshine1"), # carol picked the same password as alice
}
print("Naive user database (username -> sha256 hex digest):")
for name, digest in users.items():
print(f" {name}: {digest}")
Run it:
$ python naive_hash.py
Naive user database (username -> sha256 hex digest):
alice: 284fff3bd254b48cca05a8bfc4fad69e05cad0d086513a034a66a118829e6fa4
bob: 6ea09c25a7f1dcbd677077edaadfacdc41202e5be88e4ccf4c35207e343990bd
carol: 284fff3bd254b48cca05a8bfc4fad69e05cad0d086513a034a66a118829e6fa4
Notice something before we even try to crack anything: alice’s hash and carol’s hash are byte-for-byte identical. That alone leaks that they picked the same password, without any attacker effort at all. On a stolen table with thousands of rows, matching hashes are a free hint for which accounts share credentials, which matters a lot if even one of those users reused a password that leaked somewhere else.
Now the actual attack. A dictionary attack just means hashing a list of common or guessed passwords and comparing the results against the stolen hashes:
wordlist = [
"123456", "password", "12345678", "qwerty", "letmein",
"sunshine1", "correcthorse", "dragon", "monkey123", "iloveyou",
]
start = time.perf_counter()
cracked = {}
attempts = 0
for name, digest in users.items():
for guess in wordlist:
attempts += 1
if naive_hash(guess) == digest:
cracked[name] = guess
break
elapsed = time.perf_counter() - start
print(f"Cracked {len(cracked)} of {len(users)} accounts in {elapsed * 1000:.3f} ms")
print(f"Total guesses tried: {attempts}")
for name, pw in cracked.items():
print(f" {name}'s password is: {pw}")
Cracked 3 of 3 accounts in 0.012 ms
Total guesses tried: 19
alice's password is: sunshine1
bob's password is: correcthorse
carol's password is: sunshine1
Three out of three accounts, cracked in twelve microseconds, using a ten-word list. Real attackers use wordlists with billions of entries derived from previous breaches, and SHA-256 is fast enough on a single CPU core to try millions of them per second, which we will measure directly in the next step.
Step 2: A Salt Fixes One Problem, Not the Real One
The usual first fix people reach for is a salt: a random value, unique per password, mixed in before hashing. Let’s add one and see exactly what it does and does not solve.
# salted_hash.py
import hashlib
import os
import time
def salted_hash(password: str, salt: bytes) -> str:
return hashlib.sha256(salt + password.encode("utf-8")).hexdigest()
def make_user(password: str) -> dict:
salt = os.urandom(16)
return {"salt": salt, "hash": salted_hash(password, salt)}
users = {
"alice": make_user("sunshine1"),
"bob": make_user("correcthorse"),
"carol": make_user("sunshine1"), # same password as alice again
}
print("alice == carol hash?", users["alice"]["hash"] == users["carol"]["hash"])
alice == carol hash? False
Good: with a random per-user salt, two identical passwords no longer produce identical hashes, and the “who shares a password” leak from Step 1 is gone. A salt also defeats a rainbow table, a precomputed lookup table mapping common password hashes back to their plaintext, because the attacker would need a separate table for every possible salt value, which is computationally infeasible at scale.
What a salt does not do is make the hash function itself slower. SHA-256 is still designed to run as fast as physically possible, which is exactly backwards for password storage. Let’s measure that directly:
sample_salt = os.urandom(16)
n = 500_000
start = time.perf_counter()
for i in range(n):
hashlib.sha256(sample_salt + f"guess-{i}".encode()).hexdigest()
elapsed = time.perf_counter() - start
rate = n / elapsed
print(f"Computed {n:,} salted SHA-256 hashes in {elapsed:.3f} s")
print(f"Throughput: {rate:,.0f} hashes/second on this single CPU core")
Computed 500,000 salted SHA-256 hashes in 0.199 s
Throughput: 2,507,447 hashes/second on this single CPU core
Two and a half million guesses per second, on one core of one ordinary machine, with no special hardware at all. A consumer GPU does this hundreds of times faster still. Once an attacker has stolen the salt (which sits in plain sight right next to the hash; that is not a secret) they can brute-force any short or common password for that one user quickly. Salting fixes the “same hash reveals same password” problem. It does nothing about raw guessing speed. That is what the rest of this tutorial actually fixes.
Step 3: Slow Hashing With scrypt (Standard Library)
The property we actually want is a hash function that is deliberately, tunably slow, and ideally one that also needs a meaningful amount of memory per guess, since memory is the one resource attackers cannot cheaply multiply with more parallel hardware the way they can with raw compute. Python’s standard library has one built in: hashlib.scrypt.
OWASP’s Password Storage Cheat Sheet states its recommended minimum plainly: “If Argon2id is not available, use scrypt with a minimum CPU/memory cost parameter of (2^17), a minimum block size of 8 (1024 bytes), and a parallelization parameter of 1.” Let’s use exactly that.
# scrypt_demo.py
import hashlib, os, time
salt = os.urandom(16)
n, r, p = 2**17, 8, 1
needed_mem_bytes = 128 * r * n
print(f"OWASP's minimum scrypt config needs ~{needed_mem_bytes / (1024*1024):.0f} MiB of memory.")
hashlib.scrypt(b"Tr0ub4dor&3", salt=salt, n=n, r=r, p=p, dklen=32)
OWASP's minimum scrypt config needs ~128 MiB of memory.
Traceback (most recent call last):
...
ValueError: [digital envelope routines] memory limit exceeded
This is a real gotcha, not a hypothetical one, and it comes straight from the standard library’s own documentation. Python’s hashlib docs for scrypt spell it out: the function signature is hashlib.scrypt(password, *, salt, n, r, p, maxmem=0, dklen=64), and “maxmem limits memory (OpenSSL 1.1.0 defaults to 32 MiB).” A default of zero does not mean unlimited; it means “use OpenSSL’s own default cap,” and that cap is smaller than what OWASP’s own minimum recommendation needs. If you copy OWASP’s parameters into hashlib.scrypt without also setting maxmem, your code will raise ValueError the first time someone signs up.
The fix is to size maxmem to what your chosen n and r actually require:
digest = hashlib.scrypt(
b"Tr0ub4dor&3", salt=salt, n=n, r=r, p=p, dklen=32,
maxmem=needed_mem_bytes + 1024 * 1024,
)
Success: 304.3 ms, digest=e025e81934bac12b67df4e10...
For comparison, an intentionally weak configuration (n=2**10) computes in about 2.2 ms on the same machine, roughly 139 times faster, which also means an attacker’s brute force against it would be roughly 139 times faster. That gap is the entire point: n controls CPU and memory cost together (memory needed is approximately 128 * r * n bytes), r controls block size, and p controls parallelism. Raising these numbers slows down a legitimate login by a fraction of a second while slowing down an attacker’s entire cracking run by the same multiplier, applied to every single guess.
Step 4: bcrypt, the Industry-Standard Slow Hash
bcrypt is one of the most widely deployed password hashing algorithms in production systems, and OWASP explicitly still supports it for legacy systems that already rely on it. Install the pyca/bcrypt Python binding used throughout this tutorial with pip install bcrypt. It bundles a random salt directly into its output string and exposes a single tunable knob called the cost factor (sometimes called the work factor), which doubles the computational work for every increment.
# bcrypt_demo.py
import bcrypt, time
password = b"Tr0ub4dor&3"
h1 = bcrypt.hashpw(password, bcrypt.gensalt())
h2 = bcrypt.hashpw(password, bcrypt.gensalt())
print(f"same password, different hashes: {h1 != h2}")
print(f"both verify: {bcrypt.checkpw(password, h1)} and {bcrypt.checkpw(password, h2)}")
for rounds in (4, 8, 10, 12):
salt = bcrypt.gensalt(rounds=rounds)
start = time.perf_counter()
bcrypt.hashpw(password, salt)
elapsed = time.perf_counter() - start
print(f"cost factor {rounds:>2}: {elapsed * 1000:>8.2f} ms per hash")
same password, different hashes: True
both verify: True and True
cost factor 4: 0.72 ms per hash
cost factor 8: 11.03 ms per hash
cost factor 10: 44.40 ms per hash
cost factor 12: 177.11 ms per hash
That is a real, measured curve from a single run on this machine: roughly doubling with every +1 to the cost factor, exactly as the algorithm is designed to. OWASP’s current guidance is a cost factor of 10 or more for legacy systems that must use bcrypt specifically (Argon2id is the general first choice, covered in Step 6). Notice that hashing at cost factor 12 takes 177 milliseconds. That is imperceptible for one real login, and brutal when multiplied across billions of guesses.
Step 5: The bcrypt 72-Byte Limit (and Why the Obvious Fix Is a Bug)
bcrypt has a quirk almost nobody expects the first time they hit it: it only ever looks at the first 72 bytes of whatever you give it. Anything past that is simply irrelevant to the resulting hash. Modern versions of the Python bcrypt library guard against this by refusing long inputs outright:
# truncation_bug.py
import bcrypt, hashlib
long_prefix = b"x" * 72
pw1 = long_prefix + b"-alpha-suffix"
pw2 = long_prefix + b"-BETA-completely-different-ending"
bcrypt.hashpw(pw1, bcrypt.gensalt())
ValueError: password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])
That error message is trying to be helpful, but its own suggested fix introduces a genuine security bug. Watch what happens if you follow it literally:
t1 = pw1[:72]
t2 = pw2[:72]
print(f"pw1[:72] == pw2[:72]? {t1 == t2}")
h_naive = bcrypt.hashpw(t1, bcrypt.gensalt())
print(f"checkpw(pw2[:72], hash_of(pw1[:72])) = {bcrypt.checkpw(t2, h_naive)}")
pw1[:72] == pw2[:72]? True
checkpw(pw2[:72], hash_of(pw1[:72])) = True
pw2 is a completely different, 105-byte password from pw1, but it verifies successfully against pw1‘s hash. Slicing to 72 bytes before hashing means bcrypt truncates on your behalf anyway; you have just moved the truncation into your own code where nobody notices it. Anyone with a long passphrase who added a suffix, a punctuation mark, or a second sentence past byte 72 could unknowingly share a valid credential with every other passphrase that happens to start the same way.
This is not just a theoretical concern. The current U.S. federal password guidance, NIST Special Publication 800-63-4, is explicit on this exact point: “Verifiers SHALL request the password to be provided in full (not a subset of it) and SHALL verify the entire submitted password (e.g., not truncate it).” A naive 72-byte slice directly violates that requirement.
The correct fix, per OWASP’s own Password Storage Cheat Sheet, is to pre-hash the password with a fast, fixed-output hash first, and base64-encode that digest before handing it to bcrypt:
import base64
def hash_password(password: bytes) -> bytes:
digest_b64 = base64.b64encode(hashlib.sha256(password).digest())
return bcrypt.hashpw(digest_b64, bcrypt.gensalt())
def verify_password(password: bytes, stored_hash: bytes) -> bool:
digest_b64 = base64.b64encode(hashlib.sha256(password).digest())
return bcrypt.checkpw(digest_b64, stored_hash)
h_fixed = hash_password(pw1)
print(f"verify_password(pw1, hash_of(pw1)) = {verify_password(pw1, h_fixed)}")
print(f"verify_password(pw2, hash_of(pw1)) = {verify_password(pw2, h_fixed)}")
verify_password(pw1, hash_of(pw1)) = True
verify_password(pw2, hash_of(pw1)) = False
Fixed. Now every byte of the real password changes SHA-256’s 32-byte digest, so the entire password matters, not just its first 72 bytes.
The base64 step is not decorative. OWASP specifically warns that feeding bcrypt a raw binary digest is risky, because “the original bcrypt expects a null terminated password string,” so a digest that happens to start with a null byte can get silently cut down to nothing in some bcrypt implementations. I tested this directly against the currently installed bcrypt Python library by brute-force searching for two different SHA-256 digests that both start with a null byte, then checking whether one verified against the other’s hash:
checkpw(digest_b, hash_of(digest_a)) [both start with 0x00] = False
checkpw(b"", hash_of(digest_a)) [does it truncate to empty string?] = False
This specific Python binding turned out not to exhibit that particular failure mode. But that is one library’s current, undocumented behavior, not a guarantee, and OWASP’s own recommendation exists precisely so your code does not depend on which bcrypt implementation happens to be running underneath it. Base64-encoding the digest costs nothing and removes the question entirely.
Step 6: Argon2id, OWASP’s Current First Choice
Argon2 won the Password Hashing Competition in 2015, and its id variant (a hybrid designed to resist both GPU cracking and certain side-channel attacks) is what OWASP now lists first, ahead of both scrypt and bcrypt. Install it with pip install argon2-cffi, a Python binding documented at argon2-cffi’s own API reference, which wraps the reference C implementation.
# argon2_demo.py
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher()
print(f"defaults: time_cost={ph.time_cost}, memory_cost={ph.memory_cost} KiB, parallelism={ph.parallelism}")
stored_hash = ph.hash("Tr0ub4dor&3")
print(stored_hash)
defaults: time_cost=3, memory_cost=65536 KiB, parallelism=4
$argon2id$v=19$m=65536,t=3,p=4$dm0pPWxqvizL8MqYDt4jvg$6dKGUyDQFzuj7O9DdDO7Jkx6k1/3UJyLJwwE2+ejFzY
The library’s built-in defaults (64 MiB of memory, 3 iterations, 4 parallel lanes) already sit comfortably above OWASP’s stated floor of 19 MiB, 2 iterations, and 1 lane. The encoded hash string carries every parameter needed to verify it later, including the algorithm version and a fresh random salt, so you never have to store those separately.
Verification uses a try/except pattern rather than a plain boolean return, and this matters: a function that just returns True or False invites a caller to accidentally write if verify(...): and silently do nothing on the wrong branch. Raising an exception on mismatch forces the caller to handle failure explicitly.
try:
ph.verify(stored_hash, "Tr0ub4dor&3")
print("correct password: accepted")
except VerifyMismatchError:
print("correct password: unexpectedly rejected!")
try:
ph.verify(stored_hash, "wrong-password")
print("wrong password: unexpectedly accepted!")
except VerifyMismatchError:
print("wrong password: raised VerifyMismatchError, as expected")
correct password: accepted
wrong password: raised VerifyMismatchError, as expected
Memory cost is the parameter that matters most for resisting GPU and ASIC cracking, because those devices have thousands of small, fast compute cores but comparatively little fast memory to go around per core. Here is real measured timing as memory cost scales:
for mem_kib in (19 * 1024, 65536, 131072):
tuned = PasswordHasher(time_cost=2, memory_cost=mem_kib, parallelism=1)
tuned.hash("Tr0ub4dor&3")
memory_cost= 19456 KiB ( 19 MiB): 15.0 ms
memory_cost= 65536 KiB ( 64 MiB): 59.1 ms
memory_cost= 131072 KiB ( 128 MiB): 128.0 ms
Finally, check_needs_rehash() is how you migrate parameters forward without forcing every user to reset their password. Whenever you tighten your settings, or the library changes its own defaults, this method tells you whether an existing hash was made with today’s parameters or an older, weaker set:
old_ph = PasswordHasher(time_cost=2, memory_cost=19 * 1024, parallelism=1)
old_hash = old_ph.hash("Tr0ub4dor&3")
current_ph = PasswordHasher() # today's stronger defaults
print(current_ph.check_needs_rehash(old_hash)) # True
print(current_ph.check_needs_rehash(current_ph.hash("Tr0ub4dor&3"))) # False
True
False
Step 7: Putting It Together: Signup, Login, and Migrating Old Hashes
Now combine everything into one small, reusable module. A real system rarely starts from a clean slate: you might be migrating from an older bcrypt-only setup, or you might just be tightening your own Argon2 parameters over time. The pattern below handles both, upgrading each user’s hash the one moment you legitimately have their plaintext password: right after they type it in correctly.
# auth.py
from __future__ import annotations
import bcrypt
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerifyMismatchError
_argon2_hasher = PasswordHasher()
def hash_new_password(password: str) -> str:
"""Hash a password for a new or updated account."""
return _argon2_hasher.hash(password)
def _is_bcrypt_hash(stored: str) -> bool:
return stored.startswith(("$2a$", "$2b$", "$2y$"))
def verify_and_upgrade(stored: str, password: str) -> tuple[bool, str | None]:
"""Verify a password against a stored hash of either kind.
Returns (is_valid, new_hash_to_persist_or_None). The caller is
responsible for writing new_hash_to_persist back to storage when it
is not None; this function never touches a database itself.
"""
if _is_bcrypt_hash(stored):
if not bcrypt.checkpw(password.encode("utf-8"), stored.encode("utf-8")):
return False, None
return True, hash_new_password(password)
try:
_argon2_hasher.verify(stored, password)
except (VerifyMismatchError, InvalidHashError):
return False, None
if _argon2_hasher.check_needs_rehash(stored):
return True, hash_new_password(password)
return True, None
class UserStore:
"""A tiny in-memory stand-in for a real user table."""
def __init__(self) -> None:
self._users: dict[str, str] = {}
def add_user(self, username: str, password: str) -> None:
if username in self._users:
raise ValueError("username already exists")
self._users[username] = hash_new_password(password)
def add_legacy_bcrypt_user(self, username: str, password: str) -> None:
"""Simulates importing a user from an old bcrypt-only system."""
stored = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode()
self._users[username] = stored
def stored_hash(self, username: str) -> str | None:
return self._users.get(username)
def login(self, username: str, password: str) -> bool:
stored = self._users.get(username)
if stored is None:
# Do equivalent work even for unknown usernames, so a
# missing account and a wrong password look the same in
# terms of what work the server does.
hash_new_password(password)
return False
is_valid, new_hash = verify_and_upgrade(stored, password)
if is_valid and new_hash is not None:
self._users[username] = new_hash
return is_valid
Notice one deliberate detail in login(): when the username does not exist at all, the code still calls hash_new_password(password) before returning False, rather than returning immediately. This makes a “no such user” response take roughly the same amount of work as a “wrong password” response, instead of the missing-user path being dramatically faster and quietly confirming to an attacker which usernames do not exist.
Here is a full run against a user imported from an old bcrypt-only system, alongside a brand-new user. Notice that UserStore itself never prints anything; it is a library class, and deciding what to log is the caller’s job, not the storage layer’s. To see the migration happen, check stored_hash() before and after, the same way you would query a real database:
store = UserStore()
store.add_legacy_bcrypt_user("dave", "old-system-password")
store.add_user("erin", "a-brand-new-password")
print("Before any logins:")
print(" dave:", store.stored_hash("dave")[:20] + "...")
print()
print("store.login('dave', 'wrong-password') ->", store.login("dave", "wrong-password"))
print("store.login('dave', 'old-system-password') ->", store.login("dave", "old-system-password"))
print("store.login('erin', 'a-brand-new-password') ->", store.login("erin", "a-brand-new-password"))
print("store.login('nobody', 'anything') ->", store.login("nobody", "anything"))
print()
print("After dave's first successful login:")
print(" dave:", store.stored_hash("dave")[:20] + "...")
print(" erin:", store.stored_hash("erin")[:20] + "...")
print()
print("dave's hash is now Argon2id:", store.stored_hash("dave").startswith("$argon2id$"))
Before any logins:
dave: $2b$12$cpifIAsHq959S...
store.login('dave', 'wrong-password') -> False
store.login('dave', 'old-system-password') -> True
store.login('erin', 'a-brand-new-password') -> True
store.login('nobody', 'anything') -> False
After dave's first successful login:
dave: $argon2id$v=19$m=655...
erin: $argon2id$v=19$m=655...
dave's hash is now Argon2id: True
Dave’s stored hash silently upgraded from bcrypt to Argon2id on his very next successful login, with no password reset email, no forced re-registration, and no interruption. His failed attempt with the wrong password, correctly, changed nothing in storage at all, which the test suite below verifies explicitly rather than just eyeballing the output.
Verify Your Work
The full module deserves real automated tests, not just a manual demo run. Save this alongside auth.py as test_auth.py:
# test_auth.py
"""Pytest suite for auth.py -- run with: pytest test_auth.py -v"""
import bcrypt
from auth import UserStore, hash_new_password, verify_and_upgrade
def test_hash_new_password_uses_argon2id():
stored = hash_new_password("correct horse battery staple")
assert stored.startswith("$argon2id$")
def test_hash_new_password_is_salted():
h1 = hash_new_password("same-password")
h2 = hash_new_password("same-password")
assert h1 != h2
def test_verify_and_upgrade_correct_argon2_password():
stored = hash_new_password("my-password")
ok, new_hash = verify_and_upgrade(stored, "my-password")
assert ok is True
assert new_hash is None # fresh hash, no upgrade needed
def test_verify_and_upgrade_wrong_argon2_password():
stored = hash_new_password("my-password")
ok, new_hash = verify_and_upgrade(stored, "not-my-password")
assert ok is False
assert new_hash is None
def test_verify_and_upgrade_legacy_bcrypt_password_migrates():
legacy = bcrypt.hashpw(b"old-password", bcrypt.gensalt()).decode()
ok, new_hash = verify_and_upgrade(legacy, "old-password")
assert ok is True
assert new_hash is not None
assert new_hash.startswith("$argon2id$")
def test_verify_and_upgrade_wrong_legacy_bcrypt_password():
legacy = bcrypt.hashpw(b"old-password", bcrypt.gensalt()).decode()
ok, new_hash = verify_and_upgrade(legacy, "wrong-guess")
assert ok is False
assert new_hash is None
def test_user_store_signup_and_login():
store = UserStore()
store.add_user("alice", "s3cret-passphrase")
assert store.login("alice", "s3cret-passphrase") is True
assert store.login("alice", "wrong-guess") is False
def test_user_store_unknown_username():
store = UserStore()
assert store.login("nobody", "anything") is False
def test_user_store_migrates_legacy_bcrypt_on_first_login():
store = UserStore()
store.add_legacy_bcrypt_user("dave", "old-system-password")
before = store.stored_hash("dave")
assert before.startswith(("$2a$", "$2b$", "$2y$"))
assert store.login("dave", "wrong-password") is False
still_bcrypt = store.stored_hash("dave")
assert still_bcrypt == before # a failed login must not touch storage
assert store.login("dave", "old-system-password") is True
after = store.stored_hash("dave")
assert after.startswith("$argon2id$")
assert after != before
def test_user_store_duplicate_signup_raises():
store = UserStore()
store.add_user("erin", "first-password")
try:
store.add_user("erin", "second-password")
assert False, "expected ValueError"
except ValueError:
pass
Run the full suite:
$ pytest test_auth.py -v
test_auth.py::test_hash_new_password_uses_argon2id PASSED [ 10%]
test_auth.py::test_hash_new_password_is_salted PASSED [ 20%]
test_auth.py::test_verify_and_upgrade_correct_argon2_password PASSED [ 30%]
test_auth.py::test_verify_and_upgrade_wrong_argon2_password PASSED [ 40%]
test_auth.py::test_verify_and_upgrade_legacy_bcrypt_password_migrates PASSED [ 50%]
test_auth.py::test_verify_and_upgrade_wrong_legacy_bcrypt_password PASSED [ 60%]
test_auth.py::test_user_store_signup_and_login PASSED [ 70%]
test_auth.py::test_user_store_unknown_username PASSED [ 80%]
test_auth.py::test_user_store_migrates_legacy_bcrypt_on_first_login PASSED [ 90%]
test_auth.py::test_user_store_duplicate_signup_raises PASSED [100%]
============================= 10 passed in 1.81s ==============================
Ten tests, all passing, including the specific edge case that matters most in production: a failed login attempt against a legacy hash must never touch storage, so a typo or an attacker’s guess can never accidentally trigger a migration.
Common Mistakes to Avoid
- Never write your own hashing algorithm. Everything in this tutorial uses well-reviewed, widely deployed libraries. Cryptography is one of the few areas of software where “it seems to work” and “it is actually safe” are almost entirely unrelated claims.
- Do not impose arbitrary length caps or composition rules. Current NIST guidance (SP 800-63-4) requires verifiers to support at least 64-character passwords, explicitly forbids requiring mixtures of character types, and forbids periodic forced password rotation unless there is actual evidence of compromise. Rules like “must contain a symbol, a number, and an uppercase letter” push users toward predictable patterns like
Password1!rather than meaningfully stronger passwords. - Screen new passwords against known-breached lists. NIST’s guidance also calls for comparing chosen passwords against a blocklist of “commonly-used, expected, or compromised values,” such as previous breach corpuses. The Have I Been Pwned Pwned Passwords API is a widely used, free source for this kind of check, and it uses k-anonymity so you never send a full password or its full hash over the network.
- Rate-limit login attempts. Slow hashing raises the cost of an offline attack against a stolen database, but it does nothing to stop someone hammering your live login endpoint with guesses. That is a separate defense (account lockouts, exponential backoff, CAPTCHAs after repeated failures) and you need both.
- Never log plaintext passwords, ever, not even temporarily. Not in application logs, not in error messages, not in URLs (which get logged by proxies and browsers), not in analytics events. Once a plaintext password touches a log line, you have to treat every system that log flows through as a credential store.
- Always serve login forms over HTTPS. Password hashing protects a stolen database. It does nothing for a password sent in the clear over the network; that is Transport Layer Security’s job, and it is a prerequisite, not an alternative.
Confirm It All Works End to End
To confirm this whole setup is production-ready in your own project, walk through this checklist:
- New signups produce a hash starting with
$argon2id$, and two signups with the same password produce two different hash strings (confirms salting is working). - A correct password on a fresh Argon2id hash logs in successfully and does not trigger a rehash.
- A wrong password is rejected, and rejecting it does not modify anything in storage.
- If you are migrating from an old system, a correct password against a legacy hash both succeeds and silently upgrades that one row to Argon2id, verified by checking the stored hash’s prefix before and after.
- Run your test suite in CI on every change, so a future refactor cannot accidentally weaken verification without failing a build.
From here, natural next steps on this site build directly on what you just built: add a second factor on top of this login flow with How to Build TOTP-Based Two-Factor Authentication From Scratch in Python, issue a session token after a successful login with How to Build and Verify JSON Web Tokens From Scratch in Python, and once users are logging in, give them visibility into their own sessions with How to Detect and Revoke Suspicious Login Sessions in a Python Web App.








No Comment! Be the first one.