mrkeyoor.com_
Thu 06 Aug 08:56 UTC
PyPISecurityupdated 06 Aug 2026

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.

Verdict

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.

API stability5/5PasswordHasher has looked the same since 2016 and the project publishes an explicit backwards-compatibility policy; the last meaningful change was the 21.2.0 default swap to the RFC 9106 low-memory profile, and even the 2016-era hash_password functions were deprecated for seven years before removal
Docs5/5The Read the Docs site has a whole page on choosing parameters with worked reasoning rather than a table to copy, the API reference carries versionadded and versionchanged notes on every method, and the CHANGELOG explains the why behind each entry; the README example is a doctest, so it cannot silently rot
Maintenance4/5Pushed 2026-08-04 with Python 3.15 support and the 3.8 and 3.9 drop already queued in the changelog, 1 open issue out of 1 open issue and PR, a CII Best Practices badge and a stated no-AI-generated-code policy; the caveat is a single maintainer and a release roughly every two years, plus a second package to keep alive for the bindings
Ecosystem4/5About 17.4M downloads a week and the package Django, FastAPI tutorials, and pwdlib reach for when they mean Argon2, but the surrounding space is fragmented: passlib is stagnant, pwdlib is the newer wrapper, and plenty of projects still default to bcrypt

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
Skip it if

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")   # -> True

The 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 False

verify() 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 user

Login 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=1

memory_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_MEMORY

Run 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 False

A 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 = 131072

Django 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 False

argon2-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

PackageRegistryPick it when
bcryptPyPIYou need a hash that runs in a few megabytes of memory, or you are matching an existing system that already stores bcrypt
pwdlibPyPIYou need to verify several hash schemes at once during a migration and want the recommended-algorithm choice made for you
cryptographyPyPIYou want scrypt or PBKDF2 from the same library you already use for everything else, and you do not need Argon2 specifically
passlibPyPIYou are maintaining older code built around CryptContext and are not ready to rewrite the authentication layer