argon2-cffi
argon2-cffi hashes passwords with Argon2, the algorithm that won the Password Hashing Competition. The part you use is one class: PasswordHasher. Call ph.hash(password) and you get back a single self-describing string that contains the algorithm variant, the version, the cost parameters, the random salt, and the digest, so there is nothing extra to store in your database. Call ph.verify(hash, password) and it either returns True or raises. Defaults come from the second recommended profile in RFC 9106: Argon2id, 3 iterations, 64 MiB of memory, 4 lanes, a 16 byte salt and a 32 byte hash. There is also check_needs_rehash, which tells you whether a stored hash was made with weaker parameters than you use today, and a low_level module if you need Argon2 as a raw key derivation function. The C implementation itself lives in a separate package, argon2-cffi-bindings, so this project stays pure Python.
The right way to hash passwords in Python, with a small API that is hard to hold wrong once you know verify() raises instead of returning False. Budget the memory before you deploy, because 64 MiB per concurrent login is the whole point of the algorithm and also the thing that takes production down.
Use it if
- You are storing passwords and want the current best-practice algorithm: Argon2id resists both GPU cracking and side-channel attacks, and the library's defaults track RFC 9106 rather than folklore
- You want parameter upgrades to be routine: check_needs_rehash(hash) plus a rehash inside your login handler migrates users to stronger settings with no bulk job and no forced password reset
- You want the encoded hash to be self-contained: one string column holds the variant, version, costs, and salt, so raising your cost parameters later does not break old rows
- You need to size the cost to your hardware honestly: python -m argon2 measures a verification on the actual machine, and argon2.profiles gives you the two RFC-recommended parameter sets by name
- You need Argon2 as a key derivation function rather than a password store: low_level.hash_secret_raw gives you raw bytes of any length from a password and salt
- Your servers are memory constrained. The defaults ask for 64 MiB and 4 threads per hash, so twenty simultaneous logins want over a gigabyte of RAM; on a 512 MiB container the OOM killer arrives before the attacker does, and turning memory_cost down is exactly the knob that makes Argon2 worth using
- You are in an async framework and will not think about it. Hashing blocks the event loop for tens of milliseconds and cannot be interrupted, so it belongs in an executor, and each executor thread holds its own 64 MiB while it runs
- You have existing bcrypt or PBKDF2 hashes. This library only understands Argon2, so a migration needs your own dual-verify path or a wrapper like pwdlib that handles several schemes
- You are on Django. django.contrib.auth already ships an Argon2PasswordHasher that uses this package underneath with its own parameter choices, so calling PasswordHasher yourself duplicates it and risks the two disagreeing
- You need a full crypto toolkit. This does one thing; there is no encryption, no signing, no token format, and no session handling here
- You are targeting Pyodide or WebAssembly. parallelism must be 1 there, and any other value raises UnsupportedParametersError at PasswordHasher construction time
- You want frequent releases. It is CalVer with a slow, deliberate cadence: 21.3.0 in December 2021, 23.1.0 in August 2023, 25.1.0 in June 2025. That reflects a finished library rather than an abandoned one, but if you expect monthly activity you will read the gap wrong
Setup reality
pip install argon2-cffi installs a pure Python wheel plus argon2-cffi-bindings, which is where the CFFI layer and the vendored Argon2 C code actually live. That split is why the two packages have different Python floors (25.1.0 of the frontend declares 3.8, the bindings declare 3.9) and why an install failure is almost always about the bindings: 26 wheels cover the common Linux, macOS, and Windows targets, and anything outside them compiles from source and needs a C compiler plus libffi headers. Musl-based images and unusual architectures are the usual casualties. After that, the only real work is choosing parameters, and the honest answer is that you cannot copy them from a blog post: run python -m argon2 on the machine that will serve logins and pick the highest cost that keeps verification in the range you can tolerate, remembering that the number you measure with one process idle is not what you get at peak concurrency. Two behaviours surprise people. verify() raises rather than returning False, so a bare call with no try block treats every wrong password as a success. And the defaults changed in 21.2.0 to the RFC 9106 low-memory profile, so hashes made by older versions of this same library will report check_needs_rehash() as True the first time you upgrade.
Patterns
Hash a password and check it laterhash-and-verify
from argon2 import PasswordHasher
ph = PasswordHasher() # module-level: build once, reuse
stored = ph.hash("correct horse battery staple")
# '$argon2id$v=19$m=65536,t=3,p=4$MIIRqgvgQbgj220jfp0MPA$YfwJSVjt...'
ph.verify(stored, "correct horse battery staple") # -> TrueThe returned string carries the variant, version, cost parameters and salt, so one text column is all the schema you need and you never store a salt separately. Construct PasswordHasher once at import: it validates its parameters in __init__, and rebuilding it per request adds work an attacker does not pay.
verify() raises, it does not return Falsehandle-failures
from argon2 import PasswordHasher
from argon2.exceptions import (
VerifyMismatchError, VerificationError, InvalidHashError,
)
ph = PasswordHasher()
def check(stored: str, password: str) -> bool:
try:
return ph.verify(stored, password)
except VerifyMismatchError:
return False # wrong password, the normal case
except InvalidHashError:
log.error("corrupt hash in database")
return False
except VerificationError:
log.exception("argon2 failed for another reason")
return Falseverify() returns Literal[True] or raises, so writing if ph.verify(...) with no try block authenticates everyone whose password is wrong the moment an exception escapes into a generic handler. VerifyMismatchError is a subclass of VerificationError, so order the except clauses accordingly, and InvalidHashError is a ValueError rather than an Argon2Error.
Upgrade parameters as users log inrehash-on-login
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher(time_cost=4, memory_cost=131072) # stronger than before
def login(user, password):
try:
ph.verify(user.password_hash, password)
except VerifyMismatchError:
return None
if ph.check_needs_rehash(user.password_hash):
user.password_hash = ph.hash(password) # cleartext is right here
user.save()
return userLogin is the only moment you hold the cleartext, so this is the only place the upgrade can happen without forcing a reset. check_needs_rehash compares against this instance's exact parameters, which means it also fires when argon2-cffi's own defaults change, as they did in 21.2.0. Rehash after a successful verify, never before.
Set cost parameters, or take an RFC profiletune-parameters
from argon2 import PasswordHasher, profiles
# explicit
ph = PasswordHasher(
time_cost=3, # iterations
memory_cost=65536, # KiB, so this is 64 MiB per hash
parallelism=4, # lanes, changes the resulting hash
hash_len=32,
salt_len=16,
)
# or a named parameter set from RFC 9106
ph = PasswordHasher.from_parameters(profiles.RFC_9106_LOW_MEMORY) # 64 MiB, t=3
ph = PasswordHasher.from_parameters(profiles.RFC_9106_HIGH_MEMORY) # 2 GiB, t=1memory_cost is in kibibytes, so 65536 means 64 MiB and a stray extra zero asks for 640 MiB per concurrent login. RFC_9106_HIGH_MEMORY wants 2 GiB per hash and is meant for offline use, not a web login endpoint. Changing parallelism changes the digest, so it is not a free performance dial: old hashes stay verifiable but every one of them then needs a rehash.
Measure on the machine that will run itbenchmark-parameters
$ python -m argon2
Running Argon2id 100 times with:
hash_len: 32 bytes
memory_cost: 65536 KiB
parallelism: 4 threads
time_cost: 3 iterations
Measuring...
47.2ms per password verification
$ python -m argon2 -m 131072 -t 4 -n 50
$ python -m argon2 --profile RFC_9106_HIGH_MEMORYRun this on the production instance type, not your laptop, and remember the printed figure is a single hash on an idle box. Ten concurrent logins at 64 MiB each also contend for memory bandwidth, so real latency under load is worse than this number and the memory total is what actually decides your instance size.
Keep the timing the same for unknown usersavoid-user-enumeration
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher()
DUMMY = ph.hash("a-value-nobody-will-ever-submit")
def authenticate(email, password):
user = db.get_user(email)
if user is None:
try:
ph.verify(DUMMY, password) # burn the same time and memory
except VerifyMismatchError:
pass
return None
...Without this, a missing account returns in microseconds and a real one takes 50ms, which is a clean oracle for enumerating your user list. Compute DUMMY once at import; hashing a throwaway string per request doubles the cost of every failed login and gives an attacker a cheap way to exhaust memory.
Read the parameters out of a stored hashinspect-hash
from argon2 import extract_parameters
params = extract_parameters(
"$argon2id$v=19$m=65536,t=3,p=4$MIIRqgvgQbgj220jfp0MPA$YfwJSVjtjSU0zzV/P3S9nnQ/USre2wvJMjfCIjrTQbg"
)
params.memory_cost # 65536
params.time_cost # 3
params.type # <Type.ID: 2>
# audit the whole table before raising costs
from collections import Counter
Counter(extract_parameters(h).memory_cost for h in all_hashes)Useful for finding out how much of your table is still on old parameters before you decide whether rehash-on-login is enough or you need to force resets. It raises InvalidHashError on anything that is not a well-formed Argon2 string, including bcrypt hashes, so guard it when the column holds a mix.
Argon2 as a key derivation functionderive-raw-key
import os
from argon2.low_level import Type, hash_secret_raw
salt = os.urandom(16) # store this alongside the ciphertext
key = hash_secret_raw(
secret=b"user passphrase",
salt=salt,
time_cost=3,
memory_cost=65536,
parallelism=4,
hash_len=32, # raw bytes, feed straight to AES-256
type=Type.ID,
)low_level gives you bytes with no encoded header, which means you are responsible for storing the salt and every cost parameter yourself; get one of them wrong later and the key is unrecoverable. Use this only for deriving encryption keys. For passwords, PasswordHasher exists precisely so you do not have to keep that bookkeeping.
Do not hash on the event loopoffload-in-async
import asyncio
from concurrent.futures import ThreadPoolExecutor
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher()
# 4 threads x 64 MiB = 256 MiB ceiling for hashing
hash_pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="argon2")
async def verify(stored: str, password: str) -> bool:
loop = asyncio.get_running_loop()
try:
return await loop.run_in_executor(hash_pool, ph.verify, stored, password)
except VerifyMismatchError:
return FalseA default-parameter hash blocks for tens of milliseconds, which stalls every other request on the loop. Use a dedicated bounded pool rather than the default executor: the pool size is your real memory ceiling, and it doubles as rate limiting, since a login flood queues instead of allocating 64 MiB per attempt.
Wire it into Django's authdjango-integration
# pip install "django[argon2]"
# settings.py
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.Argon2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
]
# to change costs, subclass rather than touching argon2-cffi directly
from django.contrib.auth.hashers import Argon2PasswordHasher
class StrongArgon2Hasher(Argon2PasswordHasher):
time_cost = 4
memory_cost = 131072Django defines its own cost attributes on the hasher class, so its numbers are not argon2-cffi's defaults and constructing a PasswordHasher yourself somewhere else in the project means two different cost settings in one codebase. Keeping the old hasher second in the list is what lets existing PBKDF2 users log in and be upgraded on the way through.
Run both schemes during a migrationmigrate-from-bcrypt
import bcrypt
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher()
def check_and_upgrade(user, password: str) -> bool:
stored = user.password_hash
if stored.startswith("$argon2"):
try:
ph.verify(stored, password)
except VerifyMismatchError:
return False
if ph.check_needs_rehash(stored):
user.password_hash = ph.hash(password)
user.save()
return True
if bcrypt.checkpw(password.encode(), stored.encode()):
user.password_hash = ph.hash(password) # upgrade in place
user.save()
return True
return Falseargon2-cffi will not verify a bcrypt hash and does not pretend to; extract_parameters and verify both raise InvalidHashError on one. Dispatching on the $argon2 prefix is the whole trick. If you would rather not own this branch, pwdlib does the same job behind one interface.
Pyodide and WebAssembly need parallelism 1wasm-parallelism
from argon2 import PasswordHasher, profiles
from argon2.exceptions import UnsupportedParametersError
# picks parallelism=1 automatically when running under WASM
ph = PasswordHasher()
try:
strict = PasswordHasher(parallelism=4)
except UnsupportedParametersError:
strict = PasswordHasher(parallelism=1)
import dataclasses
params = dataclasses.replace(profiles.RFC_9106_LOW_MEMORY, parallelism=1)
ph = PasswordHasher.from_parameters(params)Since 25.1.0 the defaults adapt to WASM on their own, but any explicit parallelism other than 1 raises at construction time, not at hash time. That also means hashes produced in a browser build differ from server-side ones with the same password, since parallelism is part of the digest, so pick one value for both sides.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| bcrypt | PyPI | You need a hash that runs in a few megabytes of memory, or you are matching an existing system that already stores bcrypt |
| pwdlib | PyPI | You need to verify several hash schemes at once during a migration and want the recommended-algorithm choice made for you |
| cryptography | PyPI | You want scrypt or PBKDF2 from the same library you already use for everything else, and you do not need Argon2 specifically |
| passlib | PyPI | You are maintaining older code built around CryptContext and are not ready to rewrite the authentication layer |