Generating and hashing passwords in Python
The secrets module exists precisely so that you never have
to think about randomness again — and argon2-cffi wraps Argon2id in two calls.
1. Generate a password
secrets draws from os.urandom(), and its choice() is
already rejection-sampled, so the distribution over the alphabet is uniform:
import secrets
import string
ALPHABET = (
string.ascii_uppercase
+ string.ascii_lowercase
+ string.digits
+ "!@#$%^&*()-_=+[]{};:,.?/~"
)
def generate_password(length: int = 24, alphabet: str = ALPHABET) -> str:
"""Return a cryptographically random password.
secrets.choice() is backed by os.urandom(); the `random` module is a
Mersenne Twister and must never be used for anything security related.
"""
if length < 1 or not alphabet:
raise ValueError("length and alphabet must be non-empty")
return "".join(secrets.choice(alphabet) for _ in range(length))
Adding to the same module, to guarantee at least one character from each
set and keep the positions unbiased, pick one per set and shuffle with
SystemRandom:
def generate_structured(length: int = 24) -> str:
sets = [
string.ascii_uppercase,
string.ascii_lowercase,
string.digits,
"!@#$%^&*()-_=+[]{};:,.?/~",
]
chars = [secrets.choice(s) for s in sets]
pool = "".join(sets)
chars += [secrets.choice(pool) for _ in range(length - len(chars))]
# SystemRandom.shuffle() is a Fisher-Yates shuffle on os.urandom().
secrets.SystemRandom().shuffle(chars)
return "".join(chars)
2. Hash with Argon2id
Install with pip install argon2-cffi. Memory is given in KiB:
from argon2 import PasswordHasher
from argon2.exceptions import VerificationError
# time_cost = iterations, memory_cost = KiB, parallelism = lanes.
ph = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4,
hash_len=32, salt_len=16)
encoded = ph.hash(password)
# '$argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>'
try:
ph.verify(encoded, candidate) # raises VerificationError when wrong
# Re-hash when your parameters have moved on since this hash was stored:
if ph.check_needs_rehash(encoded):
new_encoded = ph.hash(candidate)
save_hash(user_id, new_encoded)
except VerificationError:
# Wrong password, or a malformed hash. Treat both as failure and return the
# same response you would give for a non-existent account.
deny()
Note the argument order: verify(hash, password). Passing them the other way
round is a mistake that shows up regularly in real code, and it fails in a confusing way.
3. Hash with bcrypt
pythonimport bcrypt
hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=12))
# b'$2b$12$...'
bcrypt.checkpw(candidate.encode("utf-8"), hashed) # True / False
Two limits worth designing around: bcrypt ignores everything after the first 72 bytes, and
the library rejects passwords containing a NUL byte with a
ValueError. Encode explicitly and validate input before it reaches this
point.
Pitfalls in Python
random.choice(),random.shuffle()andrandom.randint()are predictable. If you findimport randomnear a password, stop.hashlib.sha256(pw).hexdigest()— and the classicsalt + passwordconcatenation — appears in a surprising amount of older code. It is a fast hash: billions of guesses per second on a GPU.- Comparing with
==instead ofcheckpw()orverify()leaks timing information. - Storing the hash without the parameters makes a future upgrade impossible. Store the whole string — everything needed is inside it.
- Relying on
passlibfor new projects is increasingly hard to recommend; it is unmaintained, and it warns about that itself. Useargon2-cffiorbcryptdirectly.
Checking a hash by hand
When you are tuning parameters, or debugging a row that will not verify, it helps to see the hash and the comparison outside your test suite. The Verify tab performs the same Argon2id and bcrypt checks and tells you match or no match — handy for confirming that the hash in the database is really the one your code produced.
Verify an argon2-cffi or bcrypt hash — paste the password and the stored string, get a straight answer.