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.

Argon2 memory: p lanes x 4 slices; every block is G(previous block, reference block)H0BLAKE2b-512 of all inputslane 0slice 0slice 1slice 2slice 3lane 1lane 2lane 3H'(H0, 0, i), H'(H0, 1, i)Argon2id pass 0: slices 0-1 data-independentslices 2-3 and later passes: data-dependentref B[l][z]tagH'(XOR of last column)
The memory matrix. Each lane starts from two blocks derived from H0; the reference block is chosen from already-finished segments, from the current lane or another.

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)MemoryMean time per hash
t=2, m=19456, p=1 (OWASP minimum)19 MiBabout 40 ms
t=3, m=65536, p=4 (library default, RFC low-memory)64 MiBabout 73 ms
t=3, m=262144, p=4256 MiBabout 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

SourceParametersNotes
RFC 9106, first recommendedArgon2id, t=1, p=4, m=2 GiBDefault for all environments if you can afford the memory
RFC 9106, second recommendedArgon2id, t=3, p=4, m=64 MiBMemory-constrained default; argon2-cffi's default profile
OWASP minimumArgon2id, m=19 MiB, t=2, p=1Equivalent 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

  1. Type in the reference implementation and reproduce the RFC Argon2id tag; then change one constant in GB and watch it fail.
  2. Benchmark Argon2id on production hardware with the RFC procedure and record the chosen m, t and p.
  3. Put a concurrency limit, input length cap and per-account rate limit in front of every hash call.
  4. Enable check_needs_rehash upgrades so cost increases roll out at login.
  5. Load-test the login path at peak concurrency, watching memory.
  6. Review hash construction in the SHA family article.
Key takeaway: Argon2 fills a matrix of 1 KiB blocks, each a BLAKE2b-derived function of its neighbour and an earlier block, so a guess costs memory as well as time. Argon2id chooses those earlier blocks independently of the password first and dependently afterwards, which balances side channels against trade-off attacks. Choose memory first, then passes, verify against the RFC vectors, and bound concurrency so memory hardness protects you rather than your attacker.