Skillversitas

Hashing Passwords

Passwords are the one piece of data where a plain cryptographic hash is not enough. An attacker with your user table wants to test guesses as fast as possible, so password hashing must be slow on purpose (a work factor), salted per user, and verified in constant time. The standard tools are Argon2 (the current default) and PBKDF2 (the portable fallback in the standard library).

PBKDF2 from hashlib

import hashlib
import hmac
import secrets

def hash_password(password: str) -> str:
    salt = secrets.token_bytes(16)
    dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 260_000)
    return f"pbkdf2_sha256$260000${salt.hex()}${dk.hex()}"

def check_password(stored: str, password: str) -> bool:
    algo, iters, salt_hex, dk_hex = stored.split("$")
    assert algo == "pbkdf2_sha256"
    dk = hashlib.pbkdf2_hmac("sha256", password.encode(),
                             bytes.fromhex(salt_hex), int(iters))
    return hmac.compare_digest(dk.hex(), dk_hex)

The format string encodes the algorithm, iteration count, salt, and digest, so the checker can recompute and verify without any extra metadata. The iteration count (260,000 for SHA-256 in OWASP's 2023 guidance) is the work factor: it makes each guess cost milliseconds instead of nanoseconds, and it can be raised over time as hardware gets faster.

Argon2 (recommended)

Argon2 won the Password Hashing Competition and is memory-hard, which also defeats GPU/ASIC brute force. The argon2-cffi package wraps it:

pip install argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

ph = PasswordHasher()          # default: Argon2id with sane parameters

hashed = ph.hash("correct horse battery staple")
print(hashed)   # $argon2id$v=19$m=65536,t=3,p=4$...

ph.verify(hashed, "correct horse battery staple")  # OK
try:
    ph.verify(hashed, "wrong")
except VerifyMismatchError:
    print("bad password")

# rehash when parameters get stronger over time
if ph.check_needs_rehash(hashed):
    hashed = ph.hash("correct horse battery staple")

The encoded string carries its own parameters, so verification stays correct even after you upgrade the default strength; check_needs_rehash tells you when to re-encode on the user's next login.

Rules of the Road

  • Never store or log the password itself - not even encrypted with a recoverable scheme, because the key will eventually be available.
  • Compare digests with hmac.compare_digest (or the library's verify), never ==, to avoid timing leaks.
  • Apply the same hashing to passwords as to any other secret at login; hash once per stored record, salt per record.
  • Ban MD5/SHA-1 "with a salt" schemes in new code - they are fast, and fast is exactly what the attacker wants.

Summary

Hash passwords with Argon2id when you can, PBKDF2-SHA256 with a high iteration count when you need portability, always per-user salted and stored as a self-describing string, and verify with constant-time comparison. Slow on purpose, unique per user, that is the whole discipline.