Skip to content
New

DeepSeek V4.1 Flash free for 7 days: 10M tokens a day after any top-up. Until Nov 30.

How it works

Passwords and Hashing with Argon2id

Why fast hashes fail for passwords, what salts and work factors do, and how to hash and verify with Argon2id in Python using argon2-cffi.

Intermediate10 min readBy NotHoly

What this article covers

Password databases leak more often than teams plan for. When one does, the attacker gets hashes, not passwords. How much work it takes to get from one to the other depends on two choices: the function you used and the settings you gave it. This article explains why password storage needs a different kind of hash, what a salt and a work factor do, and why Argon2id is the choice current guidance points to. You will hash and verify a password in Python, read each part of the encoded hash, and rehash stored passwords when your settings change.

You need a terminal and Python 3. The lab installs one package, argon2-cffi.

Why a fast hash is the wrong tool

A hash function turns input into a fixed-size output. SHA-256 does that very quickly, which is what you want for checksums and file integrity. For passwords, speed is the problem. A stolen password table gets attacked by guessing: take a candidate, hash it, compare, repeat. If each guess is cheap, the attacker can run billions of them on graphics cards.

The OWASP Password Storage Cheat Sheet is direct about this. SHA-256 is fast, and fast is exactly what an attacker needs when the input is a password, which is a short, human-chosen string.

You can measure the gap on your own machine. The benchmark in the lab hashes a password with salted SHA-256 and with Argon2id, then prints the rate of each.

What a salt does

A salt is a random value, different for every password, stored next to the hash. It does two jobs.

  • Two users with the same password end up with different stored values. A leaked table does not show which accounts share a password.
  • It defeats precomputed tables. Without a salt, an attacker hashes a list of common passwords once and looks up every leaked hash. With a unique salt per user, each password has to be attacked on its own.

A salt is not a secret, and it does not make a weak password strong. Its job is to stop the attacker from reusing work across users. RFC 9106 recommends a 128-bit salt and allows 64 bits when space is tight. NIST SP 800-63B requires at least 32 bits. The argon2-cffi default is 16 bytes, which is 128 bits.

What a work factor does

A work factor is a setting that makes each guess expensive on purpose. Older schemes such as PBKDF2 mostly count iterations. Argon2 has three parameters.

  • Memory cost (m): the RAM, in KiB, that each hash uses.
  • Time cost (t): how many passes the function makes over that memory.
  • Parallelism (p): how many lanes can run at the same time.

Memory matters most. A GPU has thousands of small cores, and each core has little memory of its own. If each guess needs tens of megabytes, the attacker cannot run as many guesses in parallel as they could against a function that needs a few kilobytes. This property is called memory-hardness.

The work factor is a dial, not a constant. Raise it as hardware gets faster and as your login budget allows. Store the settings with each hash, so older hashes can be recognized and upgraded later. The last steps of the lab do this.

Why Argon2id

Argon2 won the Password Hashing Competition in 2015. It has three variants.

  • Argon2d picks memory addresses based on the data. It resists GPU attacks well, but that data-dependent access pattern can leak timing information.
  • Argon2i uses an access pattern that does not depend on the input. That protects against timing leaks, but it gives up some resistance to attacks that trade memory for time.
  • Argon2id runs the data-independent pattern in the first half of the first pass, then switches to the data-dependent one.

Argon2id covers both concerns. RFC 9106 recommends it as the variant to use in general, and OWASP lists it first among its password hashing options.

RFC 9106 gives two recommended settings. The first, for systems with memory to spare, is 2 GiB, 1 pass, and 4 lanes. The second, for memory-constrained environments, is 64 MiB, 3 passes, and 4 lanes. The OWASP minimum is 19 MiB, 2 iterations, and 1 lane. OWASP also lists alternatives that trade memory for passes, such as 12 MiB with 3 passes or 7 MiB with 5 passes, all at 1 lane. The argon2-cffi defaults match the RFC's second set.

Treat these as floors and starting points, not targets. A common rule of thumb is to tune the settings until one hash takes a few hundred milliseconds on your production hardware. Then check that memory per hash, multiplied by the number of logins that can arrive at the same moment, still fits your servers. That rule comes from practice, not from either standard.

NIST approaches it differently. SP 800-63B requires passwords to be salted and hashed with a suitable scheme, and says an approved scheme from SP 800-132, which covers PBKDF2, should be used. It does not name Argon2id. If a compliance program requires NIST-approved algorithms, confirm with your assessor before you choose Argon2id.

How the stored hash works, step by step

When a password hashing library stores a password, five things happen.

  1. The library generates a random salt.
  2. It runs Argon2id over the password, the salt, and the cost parameters.
  3. It encodes the variant, version, parameters, salt, and output into one string.
  4. On login, it reads the string, takes the salt and parameters from it, recomputes the output, and compares the two.
  5. If the parameters in the string are older than your current settings, you rehash after a successful login.

Step 3 is why you do not need a separate salt column. The encoded string has this format.

text
$argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>
  • argon2id is the variant.
  • v=19 is the Argon2 version, 1.3, written in decimal. In hex it is 0x13.
  • m, t, and p are memory in KiB, time cost, and parallelism.
  • <salt> and <hash> are base64 without padding. The hash is the derived output, 32 bytes by default.

The string from the lab is 97 characters long. Use a column that holds at least 255 characters, or a text type. Do not split it into columns unless you enjoy writing your own parser.

Hands-on lab

The lab uses a local virtual environment and needs no network service. The output below comes from one Windows 11 machine with Python 3.12.10, argon2-cffi 25.1.0, and argon2-cffi-bindings 26.1.0. On that machine python3 was not on the PATH in Git Bash, so the commands ran as python. Use python3 if it works on yours. Your hash strings will differ from the ones shown, because the salt is random.

1. Create the environment

bash
python3 -m venv .venv && . .venv/bin/activate
pip install argon2-cffi

On Windows, activate with .venv\Scripts\activate in PowerShell or cmd, or with . .venv/Scripts/activate in Git Bash.

2. Hash, verify, and check the parameters

bash
python3 -c "from argon2 import PasswordHasher; ph=PasswordHasher(); h=ph.hash('correct horse battery'); print(h); print(ph.verify(h,'correct horse battery')); print(ph.check_needs_rehash(h))"

Observed output:

text
$argon2id$v=19$m=65536,t=3,p=4$abVC6VF2EGJW1SCX/D6WBQ$XHKggONKypxBhdq7sWTbL/sMkglTAKu9y1FlL5p4dic
True
False

verify returns True for the right password. check_needs_rehash returns False because this hash was made with the current defaults. Those defaults are a time cost of 3, a memory cost of 65536 KiB (64 MiB), parallelism of 4, a 32-byte hash, a 16-byte salt, and the ID variant.

3. Detect a wrong password

bash
python3 -c "from argon2 import PasswordHasher; from argon2.exceptions import VerifyMismatchError; ph=PasswordHasher(); h=ph.hash('a');
try: ph.verify(h,'b')
except VerifyMismatchError: print('mismatch detected')"

Observed output:

text
mismatch detected

A wrong password does not return False. verify raises VerifyMismatchError, so the check belongs in an except block. A malformed stored string raises a different exception, InvalidHashError. Handle it as a server-side data problem, and do not let it reach the user's screen.

4. Same password, different salts

Hash the same password twice.

bash
python3 -c "from argon2 import PasswordHasher; ph=PasswordHasher(); h=ph.hash('correct horse battery'); print(h)"
python3 -c "from argon2 import PasswordHasher; ph=PasswordHasher(); h=ph.hash('correct horse battery'); print(h)"

Observed output:

text
$argon2id$v=19$m=65536,t=3,p=4$p8OU0plYOCO2/v70mjYTFA$FwhMW3ECJ0F/8rulKrJ158HjyNekURguaCIfoVk0i/o
$argon2id$v=19$m=65536,t=3,p=4$MTgAgrtBHJmc1eL2iKy8nA$h/7+8J6Cdb82XTBGWsLjbYBIh05934Yel4y6tyZ6NYg

One password, two different strings. The parameters match because both runs used the same defaults. Only the salt and the output differ. Someone reading the table cannot tell that these two accounts use the same password.

5. Read the parts of the encoded hash

Save this as parts.py.

python
from argon2 import PasswordHasher

h = PasswordHasher().hash("correct horse battery")
print(h.split("$"))

Run it with python3 parts.py. Observed output:

text
['', 'argon2id', 'v=19', 'm=65536,t=3,p=4', 'waS+Ec4iQCNIuhRQbv2sWQ', 'yLioGsHJoVv+vTu2kRoYmXTLjbbjJSe2tPSExz3Sd5A']

The empty first element comes from the leading $. After it come the variant, the version, the parameters, the salt, and the hash. Verification needs nothing else, which is why one column is enough.

6. Rehash when the parameters change

Settings change over time. This script creates a hash with weaker, older settings and then checks it against the current defaults. Save it as rehash.py.

python
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

old = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
stored = old.hash('correct horse battery')
print('stored:', stored)

new = PasswordHasher()  # current defaults
print('needs rehash under new params:', new.check_needs_rehash(stored))
try:
    new.verify(stored, 'correct horse battery')
    print('verify ok')
    if new.check_needs_rehash(stored):
        stored = new.hash('correct horse battery')
        print('rehashed:', stored)
except VerifyMismatchError:
    print('wrong password')
print('after rehash needs rehash:', new.check_needs_rehash(stored))

Run python3 rehash.py. Observed output:

text
stored: $argon2id$v=19$m=19456,t=2,p=1$EWji+p6+3HbAKY+wkykcXg$NxPq/zGPsx9okK4vQCKfZ4BVoSOqeF1QkHxd3f2g2uE
needs rehash under new params: True
verify ok
rehashed: $argon2id$v=19$m=65536,t=3,p=4$FkifxeWUHV9eU7d8JFt6mg$ZFoMkNjYqo4D/bax/jPz80Hvr4mjLWRi4TVoqCoR6ng
after rehash needs rehash: False

The order matters. Verify first, then rehash, so only a correct password reaches the upgrade. In a real application, write the new string back to the same row right after a successful login. If you skip that step, old settings stay in the table indefinitely.

7. Measure the cost on your hardware

Save this as bench.py.

python
import hashlib, time, os
from argon2 import PasswordHasher

pw = b'correct horse battery'
salt = os.urandom(16)
n = 1_000_000
t = time.perf_counter()
for _ in range(n):
    hashlib.sha256(salt + pw).digest()
dt = time.perf_counter() - t
print(f"sha256 salted: {n} hashes in {dt:.2f} s, {n/dt:,.0f} per second")

ph = PasswordHasher()
m = 10
t = time.perf_counter()
for _ in range(m):
    ph.hash('correct horse battery')
dt = time.perf_counter() - t
print(f"argon2id default: {m} hashes in {dt:.2f} s, {dt/m*1000:.0f} ms per hash")

Run python3 bench.py. Observed output:

text
sha256 salted: 1000000 hashes in 4.06 s, 246,343 per second
argon2id default: 10 hashes in 6.40 s, 640 ms per hash

These are single runs on one Windows machine. The SHA-256 line measures a Python loop, not optimized code, so it understates what dedicated hardware does with SHA-256. The Argon2id line is the cost each login pays. The exact figures matter less than the gap. A salted SHA-256 call takes about 4 microseconds here. An Argon2id call takes about 640 milliseconds, and that is the design. Tune m, t, and p on hardware that resembles production, until one login costs a few hundred milliseconds.

Common mistakes

  • Fast hashes. Using SHA-256, SHA-512, or MD5 for passwords. A password is not a file checksum.
  • Shared or predictable salts. One salt for every user, or a salt derived from the username. Generate a random salt for each password.
  • Copying settings without measuring. The library defaults are a sensible start, not a verdict for your server. Measure first.
  • Memory without a concurrency budget. 64 MiB per login sounds small until 100 logins arrive together, which is about 6.4 GB. Size memory for peak concurrent logins.
  • Comparing hash strings yourself. Call verify. Do not compare strings with ==.
  • Passwords in logs. Logs, error reports, and analytics should never contain the password, the hash, or a full login request body.
  • No upgrade path. Keep the parameters inside the stored string and rehash on successful login.
  • Treating a pepper as a fix. OWASP suggests a pepper, a secret kept outside the database, as defense in depth. The cheat sheet also notes that a pepper adds little on its own. Keep it in a secrets store if you use one, and do not use it instead of a proper hash.

How to go further

  • Read the parameter and security sections of RFC 9106 and compare them with your own threat model.
  • Read the argon2-cffi documentation for PasswordHasher, verify, and check_needs_rehash.
  • If your web framework has a password module, check whether it already uses Argon2id before you write a wrapper.
  • Before you design a login flow, get the difference between hashing, encoding, and encryption straight. The previous article covers it: encryption vs encoding vs hashing. The next one moves on to key pairs and signatures: public and private keys explained.

Sources

Sources

  1. RFC 9106: Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work Applications
  2. OWASP Cheat Sheet Series: Password Storage Cheat Sheet
  3. NIST SP 800-63B-4: Authentication and Authenticator Management
  4. argon2-cffi documentation

More in Cryptography