A stolen password database is attacked offline, with every salt and hash and hardware built to compute your hash billions of times. The defender's only lever is making each guess expensive. PBKDF2 makes a guess cost time, which GPUs and ASICs parallelise cheaply. Argon2 makes each guess cost memory as well: megabytes that must be filled and read back in an order that is hard to shortcut.
Argon2 won the Password Hashing Competition in 2015 and is specified in RFC 9106. This article builds it from the RFC, with a short Python implementation that reproduces the RFC test vectors, then covers parameters and running it on a login path without turning memory hardness into a denial-of-service. For migrating old hashes, see password hashing with bcrypt.
What Argon2 defends against
Attack cost is roughly chip area multiplied by time per guess. A PBKDF2 guess needs a few hundred bytes of state, so one GPU runs thousands at once. If every guess needs 64 MiB touched repeatedly, the attacker must buy memory capacity and bandwidth per parallel guess. That is memory-hard: the cheapest evaluation uses a lot of memory, and using less memory costs disproportionately more time.
Argon2 has three variants, distinguished by the type field y. Argon2d (y=0) reads memory at addresses derived from computed data: strong against memory-saving attacks, but its access pattern leaks through cache timing. Argon2i (y=1) uses password-independent addresses: side-channel safe, weaker against trade-offs. Argon2id (y=2) is Argon2i for the first half of the first pass, Argon2d after. RFC 9106 makes Argon2id mandatory.
Inputs and the memory matrix
The inputs are the password P, salt S, lanes p, tag length T, memory m in KiB, passes t, version 0x13, type y, and an optional secret K and associated data X. All of them, each variable-length field prefixed by its 32-bit little-endian length, are hashed with BLAKE2b-512 into a 64-byte seed H0.
Memory is m' = 4p * floor(m / 4p) blocks of 1024 bytes, arranged as p rows (lanes) of q = m'/p columns. The columns are cut into four slices; a lane's piece of a slice is a segment. The first two blocks of each lane come from H', which chains BLAKE2b to produce 1024 bytes. Every later block is B[i][j] = G(B[i][j-1], B[l][z]): a function of its left neighbour and one earlier reference block. Segments in one slice never reference each other, so lanes fill in parallel and synchronise at slice boundaries.
With m=65536 and p=4 that is 65,536 blocks (64 MiB), 16,384 columns per lane and 4,096 blocks per segment. After t passes, the last column is XORed into one block C and the tag is H'(C). On later passes the new block is XORed into the old one, the main change in version 0x13.
The compression function G
The compression function G(X, Y) takes two 1024-byte blocks. It computes R = X XOR Y, views R as an 8 x 8 grid of 16-byte registers, applies a permutation P to each row and then to each column, and returns the result XORed with R. P is one round of the BLAKE2b compression function over sixteen 64-bit words, with one change: every addition a + b becomes a + b + 2 * lo32(a) * lo32(b) modulo 2^64.
The multiply is deliberate: multipliers are deep circuits, so even custom silicon cannot make G much faster, and the cost stays in memory.
Choosing the reference block: d, i and id
For each new block the algorithm needs two 32-bit numbers, J1 and J2. Argon2d takes them from the first 64-bit word of the previous block, so the address depends on the password. Argon2i runs a block of pass, lane, slice, m', t, y and a counter through G(0, G(0, .)), giving 128 addresses per block; an observer of cache lines learns nothing about the password.
J2 mod p selects the lane (in the very first slice it is always the current lane). J1 selects a block within the reference set W: the three most recently finished segments of that lane, plus what has been computed in the current segment when the lane is your own. The mapping zz = |W| - 1 - (|W| * (J1^2 >> 32) >> 32) is deliberately non-uniform and favours recent blocks. An attacker who wants to discard old blocks and recompute them on demand finds that recent ones are needed most.
Argon2id switches rules: data-independent for slices 0 and 1 of pass 0, data-dependent elsewhere.
A reference implementation
Below is a complete Argon2 in pure Python, following the RFC step by step, including the reference-set size at segment boundaries and address blocks regenerated every 128 blocks.
import hashlib, struct
M64 = (1 << 64) - 1
le32 = lambda n: struct.pack("<I", n)
def H(data, n): # BLAKE2b with digest size n
return hashlib.blake2b(data, digest_size=n).digest()
def H_prime(data, T): # variable-length hash H'
if T <= 64:
return H(le32(T) + data, T)
r = -(-T // 32) - 2
v = H(le32(T) + data, 64)
out = v[:32]
for _ in range(r - 1):
v = H(v, 64)
out += v[:32]
return out + H(v, T - 32 * r)
def GB(v, a, b, c, d):
def mix(x, y): # x + y + 2*lo32(x)*lo32(y)
return (x + y + 2 * (x & 0xFFFFFFFF) * (y & 0xFFFFFFFF)) & M64
rot = lambda x, n: ((x >> n) | (x << (64 - n))) & M64
v[a] = mix(v[a], v[b]); v[d] = rot(v[d] ^ v[a], 32)
v[c] = mix(v[c], v[d]); v[b] = rot(v[b] ^ v[c], 24)
v[a] = mix(v[a], v[b]); v[d] = rot(v[d] ^ v[a], 16)
v[c] = mix(v[c], v[d]); v[b] = rot(v[b] ^ v[c], 63)
def P(v): # one BLAKE2b round on 16 words
for a, b, c, d in ((0,4,8,12),(1,5,9,13),(2,6,10,14),(3,7,11,15),
(0,5,10,15),(1,6,11,12),(2,7,8,13),(3,4,9,14)):
GB(v, a, b, c, d)
def G(X, Y): # blocks are lists of 128 u64 words
R = [x ^ y for x, y in zip(X, Y)]
Q = R[:]
for row in range(8): # 8 rows of 16 words
w = Q[16*row:16*row+16]; P(w); Q[16*row:16*row+16] = w
for col in range(8): # 8 columns of 2-word registers
idx = [16*k + 2*col + j for k in range(8) for j in (0, 1)]
w = [Q[i] for i in idx]; P(w)
for i, x in zip(idx, w): Q[i] = x
return [q ^ r for q, r in zip(Q, R)]
def to_words(b): return list(struct.unpack("<128Q", b))
def to_bytes(w): return struct.pack("<128Q", *w)
def argon2(P_, S, t, m, p, T, K=b"", X=b"", y=2):
h0 = H(b"".join(le32(x) for x in (p, T, m, t, 0x13, y)) +
le32(len(P_)) + P_ + le32(len(S)) + S +
le32(len(K)) + K + le32(len(X)) + X, 64)
q = (m // (4 * p)) * 4 # columns per lane
seg = q // 4 # blocks per segment
B = [[None] * q for _ in range(p)]
for i in range(p):
B[i][0] = to_words(H_prime(h0 + le32(0) + le32(i), 1024))
B[i][1] = to_words(H_prime(h0 + le32(1) + le32(i), 1024))
zero = [0] * 128
for r in range(t):
for sl in range(4):
for i in range(p):
indep = y == 1 or (y == 2 and r == 0 and sl < 2)
ctr, addr = 0, None
start = 2 if r == 0 and sl == 0 else 0
for k in range(start, seg):
j = sl * seg + k
if indep:
if addr is None or k % 128 == 0:
ctr += 1
z = [r, i, sl, p * q, t, y, ctr] + [0] * 121
addr = G(zero, G(zero, z))
rand = addr[k % 128]
else:
rand = B[i][(j - 1) % q][0]
J1, J2 = rand & 0xFFFFFFFF, rand >> 32
lane = i if (r == 0 and sl == 0) else J2 % p
same = lane == i
if r == 0: # size of reference set W
w = (k - 1) if sl == 0 else sl * seg + (k - 1 if same else -(k == 0))
first = 0
else:
w = q - seg + (k - 1 if same else -(k == 0))
first = 0 if sl == 3 else (sl + 1) * seg
x = (J1 * J1) >> 32
zz = w - 1 - ((w * x) >> 32)
ref = B[lane][(first + zz) % q]
new = G(B[i][(j - 1) % q], ref)
B[i][j] = new if r == 0 else [a ^ b for a, b in zip(new, B[i][j])]
C = B[0][q - 1]
for i in range(1, p):
C = [a ^ b for a, b in zip(C, B[i][q - 1])]
return H_prime(to_bytes(C), T)The check is the RFC's Argon2id test vector: 32 KiB, three passes, four lanes, with a secret and associated data. Passing y=0 and y=1 reproduces the RFC's Argon2d and Argon2i tags as well.
tag = argon2(b"\x01" * 32, b"\x02" * 16, t=3, m=32, p=4, T=32,
K=b"\x03" * 8, X=b"\x04" * 12, y=2)
assert tag.hex() == ("0d640df58d78766c08c037a34a8b53c9"
"d01ef0452d75b65eb52520e96b01e659") # RFC 9106, 5.3
Worked example: test vectors and real timings
The implementation reproduces all three RFC 9106 tags and matched argon2-cffi 25.1.0's hash_secret_raw on random parameter sets, including memory not a multiple of 4p and segments over 128 blocks. It is for reading only: slow, not constant-time, and it never wipes memory.
| Setting (argon2-cffi 25.1.0, Argon2id) | Memory | Mean time per hash |
|---|---|---|
| t=2, m=19456, p=1 (OWASP minimum) | 19 MiB | about 40 ms |
| t=3, m=65536, p=4 (library default, RFC low-memory) | 64 MiB | about 73 ms |
| t=3, m=262144, p=4 | 256 MiB | about 290 ms |
Those timings are one laptop, five hashes each; measure on production hardware. Note the scaling: quadrupling memory roughly quadrupled time, because filling memory is the work.
Trade-off attacks and why Argon2id is the default
RFC 9106 quantifies trade-off attacks as the factor by which an attacker can reduce the time-area product. For t-pass Argon2d, and for multi-pass Argon2id, the best known is the ranking attack at a factor of about 1.33. For one-pass Argon2id, combining a low-storage attack on the data-independent half with the ranking attack on the other gives about 2.1. Argon2i is weaker: attacks by Alwen and Blocki showed that small pass counts leave a significant advantage, and the RFC says passes must exceed log2(memory) minus 26 to rule those attacks out completely. Hence Argon2id as the default.
Choosing parameters
| Source | Parameters | Notes |
|---|---|---|
| RFC 9106, first recommended | Argon2id, t=1, p=4, m=2 GiB | Default for all environments if you can afford the memory |
| RFC 9106, second recommended | Argon2id, t=3, p=4, m=64 MiB | Memory-constrained default; argon2-cffi's default profile |
| OWASP minimum | Argon2id, m=19 MiB, t=2, p=1 | Equivalent alternatives trade memory for passes, from 46 MiB/t=1 to 7 MiB/t=5 |
The RFC's own procedure is the right way to choose: fix Argon2id and p=4, set m to the most memory each call can afford, then raise t until the latency budget is used. If even t=1 is too slow, reduce memory. Use a 16-byte random salt per password and a 16- to 32-byte tag. Because the defender's budget is usually latency, the RFC notes that for Argon2id one pass with more memory beats more passes with less memory.
p changes the output, so it is a parameter, not just a thread count; on a busy server concurrent logins already use every core, so lanes buy little latency.
Running Argon2 on a login path
Memory hardness cuts both ways: at 64 MiB per hash, 200 concurrent logins need 12.5 GiB, and sending them is far cheaper than serving them. Bound concurrency, cap password length and rate-limit by account and source before hashing.
import threading
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, InvalidHashError
ph = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4) # 64 MiB
MAX_CONCURRENT = 16 # 16 x 64 MiB = 1 GiB ceiling
slots = threading.BoundedSemaphore(MAX_CONCURRENT)
def verify_login(stored_hash: str, password: str) -> tuple[bool, str | None]:
if len(password) > 1024: # cap input before any hashing work
return False, None
if not slots.acquire(timeout=2.0): # shed load instead of swapping
raise TimeoutError("password hashing saturated")
try:
ph.verify(stored_hash, password)
new_hash = ph.hash(password) if ph.check_needs_rehash(stored_hash) else None
return True, new_hash # caller persists new_hash if set
except (VerifyMismatchError, InvalidHashError):
return False, None
finally:
slots.release()The stored value is a PHC string such as $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>, so parameters travel with each hash. check_needs_rehash compares those with the current settings, so costs can rise over time with each account upgraded at its next login. A pepper (server-side secret) can go in Argon2's K input or an HMAC of the password; plan its rotation before adopting it. Run hashing on a dedicated worker pool.
Failure modes
- Unbounded concurrency. Memory spikes, the host swaps or is OOM-killed, and logins fail for everyone.
- Choosing Argon2i or Argon2d by name. Use Argon2id unless you have a specific, analysed reason.
- Parameters tuned on a laptop. Small container memory limits turn a 70 ms hash into a timeout.
- Reused or short salts. They let one computation test a guess against many accounts.
- Using a password hash as a key KDF without thought. For keys from high-entropy secrets, use HKDF instead, as explained in PBKDF2 and KDFs.
What to do next
- Type in the reference implementation and reproduce the RFC Argon2id tag; then change one constant in
GBand watch it fail. - Benchmark Argon2id on production hardware with the RFC procedure and record the chosen
m,tandp. - Put a concurrency limit, input length cap and per-account rate limit in front of every hash call.
- Enable
check_needs_rehashupgrades so cost increases roll out at login. - Load-test the login path at peak concurrency, watching memory.
- Review hash construction in the SHA family article.