Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 053 / 100

Hashing passwords in Python: what developers should know

Use a slow, salted algorithm.

Expona Admin8 min read

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.

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

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

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

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

python
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 GPU

And even this "improved" version falls short:

python
# 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.

python
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 True

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

  1. Per-account limits—lock an account after N failed attempts (e.g., 5 failures in 15 minutes).
  2. Per-IP limits—throttle login attempts from a single IP to prevent credential stuffing.
  3. Progressive delays—increase the response time after each failure so brute-force attempts become prohibitively slow.

Here's a simple implementation using Redis:

python
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-cffi generate 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-cffi or bcrypt, 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles