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 |
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.
# 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.
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.
Related
- Password Storage & Credential Hygiene — the parent guide covering the whole credential lifecycle
- Breached Password Screening With k-Anonymity — the control that hashing strength cannot substitute for
- Rate Limiting Login Endpoints Against Credential Stuffing — bounding the online attacker while hashing bounds the offline one
- Secure Authentication & Session Architecture — how credential storage fits the wider authentication design