Argon2id Parameter Tuning for Web Applications

Argon2id has three knobs and almost every codebase turns them by copying a snippet. That is unfortunate, because the right values depend on hardware you own and traffic you can measure, and the wrong values fail in two opposite directions: too low and the stored hashes are cheap to attack, too high and a login spike takes the service down by exhausting memory.

This guide tunes the parameters by measurement. It sets a floor from published guidance, benchmarks at realistic concurrency, caps simultaneous verifications so the login path cannot exhaust the host, and records the parameters so the values can be raised later without a password reset campaign. It is part of the Password Storage & Credential Hygiene guide within Secure Authentication & Session Architecture.

Prerequisites

  • An Argon2 binding for your runtime, pinned to a specific version
  • A staging host with the same instance type as production
  • Login-rate metrics: requests per second at peak and the concurrency that implies
  • Rehash-on-login already implemented, so parameter changes apply without user action

Expected Outcomes

  • Parameters chosen from a benchmark on production-equivalent hardware
  • Verification latency inside your latency budget at peak concurrency
  • A concurrency cap that bounds total memory used by hashing
  • Parameters recorded per hash and re-measured when the instance type changes

Step 1: Start From a Floor, Not From Zero

Published guidance gives a defensible minimum. Treat it as a floor to exceed, not a target to hit.

Setting Reasonable floor What raising it costs you What raising it costs the attacker
Memory 46 MiB Memory per concurrent verification Memory per parallel guess — the expensive axis
Iterations 2 CPU time per verification, linearly CPU time per guess, linearly
Parallelism 1 Cores committed to one verification Little, since attackers parallelise across guesses anyway
Salt length 16 bytes Nothing measurable Removes any shared precomputation
Output length 32 bytes Nothing measurable Nothing — it is not the bottleneck
Which Knob Costs the Attacker Most Three parameters compared on who pays. Memory costs the defender a fixed allocation per concurrent login while costing the attacker memory for every parallel guess, which is the hardest resource to scale on cracking hardware. Iterations cost both sides linearly. Parallelism costs the defender cores while giving the attacker almost nothing, since they parallelise across guesses regardless. Parameter You pay They pay Memory an allocation per concurrent login memory per parallel guess — the hardest resource to scale cheaply Iterations CPU time, linearly CPU time, linearly — a fair trade, not a win Parallelism cores committed to one verification very little — they parallelise anyway

Step 2: Benchmark at Realistic Concurrency

A single-threaded benchmark on an idle machine tells you almost nothing. Measure the shape you will actually experience.

A Representative Sweep at 24 Concurrent Verifications Five configurations measured at the same concurrency. As memory rises, the ninety-fifth percentile latency and the peak memory both climb. The chosen row is the highest setting whose latency stays inside the login budget and whose peak memory leaves headroom on the instance. Configuration p95 latency peak memory verdict 46 MiB, 2 iterations 81 ms 1.1 GiB at the floor, room above 64 MiB, 3 iterations 168 ms 1.5 GiB comfortable 96 MiB, 3 iterations 242 ms 2.3 GiB chosen 192 MiB, 4 iterations 640 ms 4.6 GiB exceeds the instance Peak memory is memory cost multiplied by the concurrency cap — which is why the two are chosen together.
# bench_argon2.py — sweep parameters at the concurrency your peak login rate implies.
import time, statistics
from concurrent.futures import ThreadPoolExecutor
from argon2 import PasswordHasher

CONCURRENCY = 24          # peak logins per second × p95 verification seconds, rounded up
SAMPLES = 200

def bench(memory_mib: int, iterations: int, parallelism: int) -> dict:
    ph = PasswordHasher(memory_cost=memory_mib * 1024, time_cost=iterations,
                        parallelism=parallelism, hash_len=32, salt_len=16)
    stored = ph.hash("benchmark-password")

    def one():
        t0 = time.perf_counter()
        ph.verify(stored, "benchmark-password")
        return time.perf_counter() - t0

    with ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:
        times = list(pool.map(lambda _: one(), range(SAMPLES)))

    return {
        "memory_mib": memory_mib, "iterations": iterations, "parallelism": parallelism,
        "p50_ms": round(statistics.median(times) * 1000, 1),
        "p95_ms": round(sorted(times)[int(0.95 * len(times))] * 1000, 1),
        "peak_memory_mib": memory_mib * CONCURRENCY,
    }

for mem in (46, 64, 96, 128, 192):
    for iters in (2, 3, 4):
        print(bench(mem, iters, parallelism=2))

Pick the highest configuration where the ninety-fifth percentile stays inside your login latency budget and the peak memory column fits the instance with room for the rest of the application. On a typical four-vCPU web instance that usually lands around 64 to 96 MiB with three iterations — but the number that matters is the one your own run produces.


Step 3: Cap Concurrent Verifications

Parameters and concurrency have to be chosen together, because the product of the two is the memory the login path can demand.

With a Cap the Memory Plateaus; Without One It Tracks Traffic Under a login spike, an uncapped verification path allocates memory proportional to the request rate until the instance is exhausted. A capped path plateaus at the cap multiplied by the memory cost, sheds excess requests with a short retry, and keeps the rest of the application responsive. No cap memory tracks the request rate every queued request holds an allocation the container is killed, or swaps A login spike becomes a full outage, including for users not logging in. Capped, with shedding memory plateaus at cap times cost excess requests get 503 and a short retry the rest of the application stays up Some logins fail briefly, which is a far better outcome than everything failing.
import { Sema } from 'async-sema';

// peak memory = MEMORY_MIB * MAX_CONCURRENT_HASHES, and must fit with headroom.
const MAX_CONCURRENT_HASHES = 24;
const gate = new Sema(MAX_CONCURRENT_HASHES);

export async function verifyPassword(stored: string, candidate: string): Promise<boolean> {
  if (gate.nrWaiting() > QUEUE_LIMIT) {
    metrics.increment('login.shed');
    throw new HttpError(503, 'try again shortly');     // shed, do not queue indefinitely
  }
  await gate.acquire();
  try {
    return await argon2.verify(stored, pepper(candidate));
  } finally {
    gate.release();
  }
}

Shedding beyond a queue limit is deliberate. An unbounded queue converts a login spike into a slow-motion outage where every request eventually times out; a 503 with a short retry keeps the rest of the application responsive and tells clients something actionable.


Verification

# 1. Confirm the deployed parameters, read from a real stored hash.
python3 - <<'PY'
from argon2 import extract_parameters
h = open('/tmp/sample-hash.txt').read().strip()
p = extract_parameters(h)
print(p)                       # memory_cost, time_cost, parallelism as actually stored
assert p.memory_cost >= 46 * 1024 and p.time_cost >= 2
PY

# 2. Latency under concurrency, against the running service.
hey -n 400 -c 24 -m POST -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"wrong"}' \
  http://localhost:3000/auth/login | grep -E 'Requests/sec|95%'

# 3. Memory ceiling holds: watch resident memory while the load runs.
#    Expect a plateau near MEMORY_MIB × MAX_CONCURRENT_HASHES, not unbounded growth.

The third check is the one that catches a missing cap: without it, resident memory tracks the request rate rather than plateauing.


Troubleshooting

Symptom Likely cause Fix
Login latency fine in staging, poor in production Benchmarked without concurrency Re-run the sweep at the real peak concurrency
Container restarts under a login spike Memory cost times concurrency exceeds the limit Lower memory, lower the cap, or raise the instance size
Verification much slower after a library upgrade Default parameters changed, or a pure-language fallback is in use Pin the version, assert the native binding is loaded at boot
Old accounts still on weak parameters Rehash-on-login not implemented Add the upgrade in the login path; parameters only move when users authenticate
Different parameters across instances Configuration drift between environments Read the parameters from one source and expose them on the health endpoint

Common Implementation Mistakes


Frequently Asked Questions

Should I raise memory or iterations first?

Memory. Iterations raise the work per guess linearly for both sides, whereas memory raises the hardware cost per parallel guess, which is exactly what makes large cracking rigs expensive to build. Raise memory until the host is uncomfortable at peak concurrency, then use iterations for fine adjustment. Parallelism should reflect the cores you are prepared to dedicate to a single verification and usually stays low on a shared web server.

What happens if I set memory too high?

Every simultaneous verification allocates that much memory, so a login spike becomes a memory exhaustion event. At 256 MiB per hash and forty concurrent logins you have asked the host for ten gigabytes on the login path alone. That is why the concurrency cap belongs to the tuning exercise rather than being an optimisation afterwards: the safe setting is whatever peak concurrency multiplied by memory cost fits inside the instance with headroom.

How often should I re-tune?

Whenever the instance type changes, and at least annually otherwise. Attacker hardware improves continuously, so parameters chosen three hardware generations ago are meaningfully weaker than they were on the day they were set. Because rehash-on-login upgrades users transparently as they authenticate, raising the parameters costs a configuration change and a deploy rather than a reset campaign.