Every week, we see authentication breaches that stem from weak password handling. Hashing passwords in Python: what developers should know isn't just about picking a library—it's about understanding why certain algorithms exist and how to integrate them safely into your backend. If you're building a production system, this decision affects your entire user base's security posture.
Why password hashing matters
When a user creates an account, you're trusted with their password. The moment you store it in plaintext or use a fast, unsalted hash, you've handed attackers a golden ticket. A breach of your database becomes a breach of your users' accounts across every platform they use that password on.
The goal of password hashing is simple but demanding: make it computationally expensive and time-consuming to crack passwords, even if an attacker gains access to your database. A good hashing algorithm should be:
- Slow—taking milliseconds to compute, not microseconds
- Salted—including random data so identical passwords produce different hashes
- Adaptive—allowing you to increase the cost factor as hardware improves
Fast algorithms like MD5 or SHA-256 were designed for checksums and digital signatures, not password storage. Modern GPUs can compute billions of SHA-256 hashes per second. Slow, memory-hard algorithms like Argon2 are designed to resist this kind of brute-force attack.
Argon2-cffi or bcrypt: the two production-grade options
You have two mainstream choices in Python: argon2-cffi and bcrypt. Both are battle-tested, both win security audits, and both are better than anything else you might consider.
Argon2-cffi
Argon2 won the Password Hashing Competition in 2015. It's memory-hard, meaning it's resistant to GPU and ASIC attacks because it uses both CPU and RAM efficiently. argon2-cffi is the Python binding to the reference C implementation.
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
hasher = PasswordHasher()
# Hashing a password
password = "user_input_password"
hashed = hasher.hash(password)
print(hashed)
# Output: $argon2id$v=19$m=102400,t=2,p=8$...$...To verify a password later:
try:
hasher.verify(hashed, password)
print("Password correct")
except VerifyMismatchError:
print("Password incorrect")Argon2's default parameters are tuned conservatively, which is good for most applications. The output format encodes the algorithm version and parameters, so you can safely change them over time without breaking existing hashes.
Bcrypt
Bcrypt has been around since 1999 and is still considered excellent. It's simpler than Argon2 and uses a higher cost factor (work factor) to stay slow as hardware improves. The bcrypt library in Python is straightforward:
import bcrypt
# Hashing a password
password = b"user_input_password"
salt = bcrypt.gensalt(rounds=12)
hashed = bcrypt.hashpw(password, salt)
print(hashed)
# Output: b'$2b$12$...'To verify:
if bcrypt.checkpw(password, hashed):
print("Password correct")
else:
print("Password incorrect")Note that bcrypt requires bytes, not strings. The rounds parameter (10–14 is typical) controls the cost factor. Each increment doubles the time to hash, so choose carefully.
Which one to pick?
Both are solid. Argon2 is newer and theoretically superior for resisting specialized hardware. Bcrypt is simpler, more widely understood, and battle-tested across decades. For most new projects, we recommend argon2-cffi unless your ops team has strong expertise with bcrypt already. If you're migrating an existing system, stick with what you have unless there's a compelling reason to change.
Never MD5 or SHA-256 alone
We need to be blunt: if you see production code using MD5, SHA-256, or any cryptographic hash without a proper password-hashing library, flag it immediately. These functions are fast by design. SHA-256 can compute millions of hashes per second on a laptop, and billions on specialized hardware.
Developers sometimes justify this with "but we salt it" or "but we iterate it." The reality:
- Salting alone doesn't slow down an attacker. It prevents rainbow tables, but an attacker with your database will just compute hashes for each salt.
- Iterating SHA-256 in a loop (stretching) helps, but it's not adaptive. You pick a fixed iteration count, and as hardware improves, that becomes insecure.
- Neither MD5 nor SHA-256 uses memory, so they're vulnerable to parallel GPU attacks.
Here's what insecure code looks like:
import hashlib
import os
# DON'T DO THIS
password = "user_password"
salt = os.urandom(16)
hashed = hashlib.sha256((salt + password.encode()).encode()).hexdigest()
# This is breakable in seconds with a GPUAnd even this "improved" version falls short:
# STILL DON'T DO THIS
iterations = 100000
hashed = password.encode()
for _ in range(iterations):
hashed = hashlib.sha256(hashed).digest()
# Attackers can parallelize this. It's also not adaptive.Use a purpose-built password-hashing library. Period.
Rehash on login when parameters change
One of the strengths of argon2-cffi and bcrypt is that their output format includes the algorithm parameters. As hardware improves or best practices evolve, you can increase the cost factor without invalidating existing hashes.
The pattern is: on successful login, check if the hash needs upgrading, and if so, recompute and store the new hash.
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, InvalidHashError
hasher = PasswordHasher()
def authenticate_user(username: str, password: str, db) -> bool:
user = db.get_user(username)
if not user:
return False
try:
hasher.verify(user.password_hash, password)
except VerifyMismatchError:
return False
except InvalidHashError:
# Hash format is unrecognized; user needs to reset password
return False
# Password is correct. Check if rehashing is needed.
if hasher.check_needs_rehash(user.password_hash):
new_hash = hasher.hash(password)
db.update_password_hash(username, new_hash)
print(f"Rehashed password for {username}")
return TrueThis approach means your security automatically hardens as you deploy new parameters. Users with old hashes will gradually migrate to new ones as they log in. Users who never log in will stay on the old hash (acceptable—if they're dormant, a breach is less critical).
Rate-limit login attempts
Even with perfect hashing, brute-force attacks remain a threat if you don't rate-limit failed login attempts. An attacker can try millions of passwords against a single account or probe hundreds of accounts with common passwords.
A robust rate-limiting strategy includes:
- Per-account limits—lock an account after N failed attempts (e.g., 5 failures in 15 minutes).
- Per-IP limits—throttle login attempts from a single IP to prevent credential stuffing.
- Progressive delays—increase the response time after each failure so brute-force attempts become prohibitively slow.
Here's a simple implementation using Redis:
import redis
import time
from datetime import datetime, timedelta
redis_client = redis.Redis(host="localhost", port=6379, decode_responses=True)
def check_rate_limit(username: str, ip_address: str) -> tuple[bool, str]:
"""Returns (allowed, message)"""
# Per-account limit: 5 failures in 15 minutes
account_key = f"login_failures:{username}"
account_failures = int(redis_client.get(account_key) or 0)
if account_failures >= 5:
return False, "Account temporarily locked. Try again in 15 minutes."
# Per-IP limit: 20 failures in 5 minutes
ip_key = f"login_failures_ip:{ip_address}"
ip_failures = int(redis_client.get(ip_key) or 0)
if ip_failures >= 20:
return False, "Too many login attempts from this IP. Try again later."
return True, ""
def record_failed_login(username: str, ip_address: str):
"""Increment failure counters"""
account_key = f"login_failures:{username}"
ip_key = f"login_failures_ip:{ip_address}"
# Set expiry so old failures don't count
redis_client.incr(account_key)
redis_client.expire(account_key, 900) # 15 minutes
redis_client.incr(ip_key)
redis_client.expire(ip_key, 300) # 5 minutes
def clear_failed_login(username: str):
"""Clear counters on successful login"""
account_key = f"login_failures:{username}"
redis_client.delete(account_key)
# In your login endpoint
def login(username: str, password: str, ip_address: str, db) -> dict:
allowed, message = check_rate_limit(username, ip_address)
if not allowed:
return {"success": False, "error": message}
if not authenticate_user(username, password, db):
record_failed_login(username, ip_address)
return {"success": False, "error": "Invalid credentials"}
clear_failed_login(username)
return {"success": True, "token": generate_session_token(username)}This approach is transparent to the user on success but hardens your system against attacks. Combine it with alerting so you're notified of suspicious patterns.
Common mistakes
- Storing passwords in plaintext or with reversible encryption—Hashing is one-way; you verify by hashing the input and comparing.
- Using a weak salt or reusing salts—Libraries like
argon2-cffigenerate strong, unique salts automatically. Don't implement your own. - Logging or exposing password hashes—Hashes are sensitive data. Don't print them in logs or error messages.
- Ignoring timing attacks—Always use constant-time comparison. Libraries handle this for you; don't manually compare hashes.
- Forgetting to handle Unicode in Python 3—Bcrypt expects bytes; encode strings with UTF-8. Argon2 handles this more gracefully, but be consistent.
- Not upgrading old hashes—If you migrate from MD5 (shudder) to Argon2, users stay vulnerable until they log in. Provide a password reset flow so dormant users can upgrade.
Key takeaways
- Use
argon2-cffiorbcrypt, never roll your own or use fast hashes like SHA-256 for passwords. - Argon2 is memory-hard and adaptive; bcrypt is simpler and battle-tested. Both are production-grade.
- Rehash on successful login when your algorithm's parameters change to keep security up to date.
- Implement rate-limiting on login attempts per account and per IP to prevent brute-force attacks.
- Treat password hashes as sensitive data—don't log them, expose them in error messages, or store them insecurely.
- Test your password verification flow thoroughly, especially edge cases like Unicode and very long passwords.






Comments
Loading comments…