October 25, 2026
Ten Logins, One Gigabyte: Benchmarking Django’s Argon2 Settings Under Load
I removed 117 queries from a Django login and its throughput rose 1.4×. Changing three Argon2 numbers raised it another 5.5×. Here is what…
By Olawuyi Olushola
6 min read
I removed 117 queries from a Django login and its throughput rose 1.4×. Changing three Argon2 numbers raised it another 5.5×. Here is what password hashing costs under concurrency, measured on one 4-CPU server, and how to choose settings deliberately.
The surprise
My login endpoint ran 124 SQL queries for a user with 50 projects, a textbook N+1 problem. I fixed it: 7 queries. Under Gunicorn, with 10 clients logging in continuously, login still took 633 ms at the median and the server managed 15.4 logins per second.
The real cost was the password check. Django defaults to PBKDF2, but its docs recommend Argon2, and this app used it. At its default settings, Django's Argon2 hasher fills 100 MiB of memory per login. Ten logins at once on 4 CPUs need close to 1 GB just for hashing, and each waits for the others.
With OWASP's published minimum for Argon2id instead, the same test ran at 84.5 logins per second with a 106 ms median, using about a fifth of the memory. Whether that minimum is right for you is a separate question, covered at the end.
Argon2 in one minute
A password hash is meant to be slow: if your database leaks, every guess costs the attacker the same work. Argon2, winner of the 2015 Password Hashing Competition, makes each hash cost CPU time and memory, which keeps GPU cracking expensive. Django's hasher uses Argon2id with three settings:
- memory_cost: memory per hash. Default 100 MiB.
- time_cost: passes over that memory. Default 2.
- parallelism: lanes the memory is split into, which can be filled at the same time. Default 8.
The test
A small sample API (users, organizations, projects, JWT login) on Gunicorn 23 with 9 sync workers, on a 4-vCPU machine with 16 GB of RAM, Python 3.13, Django 5.2 and PostgreSQL 16 on the same host. I compared three settings:
- Django default: 100 MiB, 2 passes, 8 lanes.
- RFC 9106 #2: 64 MiB, 3 passes, 4 lanes, the RFC's option for memory-constrained systems.
- OWASP minimum: 19 MiB, 2 passes, 1 lane.
Each benchmark ran 3 times on a fresh server. The screenshots show the median with the lowest and highest run in brackets; the text quotes medians. Bursts are 10 requests released together by a barrier. Memory is the kernel's cgroup peak above idle Gunicorn (about 356 MiB). The load client shared the machine but used at most 3.5% of its CPU. The full methodology is in the repository linked at the end.
One login is fast. Ten at once are not.
- A busy moment cost 6.5× a quiet one. With the defaults, one login took 105 ms; ten arriving together took 687 ms to finish.
- Testing one at a time understates the gap. Alone, the OWASP minimum was 2.6× faster than the default (41 vs 105 ms). Ten at once, it was 5.5× faster (124 vs 687 ms).
- Wrong passwords cost nearly the same: 672 ms for ten failed logins against 687 ms for ten good ones. Django hashes wrong passwords too, and runs a dummy hash for unknown emails. That narrows account-enumeration timing differences without removing them. Anyone can create this load without knowing a password.
- Memory scaled with logins in flight: 920 MiB above idle with the defaults, about 1.25 GiB in total, enough to get workers killed in a 1 GiB container. The OWASP minimum needed 193 MiB.
How many passwords can one server check?
I ran sustained logins at 1, 2, 4, 8 and 16 clients.
Each setting hits a ceiling: about 16 logins per second with the defaults, 17 with RFC 9106 #2 and 96 with the OWASP minimum. Past it, more clients only add waiting. With the defaults, p95 doubled from 307 to 593 to 1,183 ms as clients went from 4 to 8 to 16.
Is the ceiling real, or caused by the load client? A CPU-bound server can serve at most cores ÷ CPU per login, assuming the cores serve only the app (the load client used 1.4% of the machine or less here). With the defaults, each login cost 245 ms of server CPU (Gunicorn plus PostgreSQL), a limit of 4 ÷ 0.245 = 16.3 per second. The server reached 97% of it, and the other settings 94–96%. The ceiling is the server running out of CPU, so it is the number to plan with. Because wrong passwords cost nearly the same, it is also roughly the rate at which anyone can saturate your login.
Why: lanes spread the work, they don't reduce it
Lanes are part of the algorithm; changing them changes the hash. How many threads compute them is up to the implementation. Timing one hash at a time showed argon2-cffi starting one thread per lane:
At 100 MiB, CPU time stayed between 222 and 249 ms whatever the lane count. More lanes only cut wall time while cores were free: from 212 ms with 1 lane to 79 ms with 4. On this 4-core machine, 8 lanes were no faster than 4. On a busy server no core is free, so every login pays its full CPU time. That is why the gap widens under load.
Memory follows a first-order estimate. Each sync worker runs at most one hash, so hash memory ≈ workers × memory_cost. For 9 workers that predicts 900, 576 and 171 MiB; I measured 920, 585 and 193 MiB above idle. Add idle memory and headroom before comparing with your container limit. With gthread or async workers, count requests in flight instead of workers.
Choosing your parameters
Aim for the strongest setting your servers can run at peak, not the fastest one. Treat the OWASP minimum as a floor: set a latency and memory budget, estimate peak logins per server (failed ones included), benchmark stronger candidates on your hardware, and pick the strongest that fits. On this server, RFC 9106 #2 tops out near 17 logins per second: enough for 5 per second at peak, not for 20.
The change itself is a subclass:
from django.contrib.auth.hashers import Argon2PasswordHasher
class TunedArgon2PasswordHasher(Argon2PasswordHasher):
time_cost = 2
memory_cost = 19 * 1024 # KiB
parallelism = 1from django.contrib.auth.hashers import Argon2PasswordHasher
class TunedArgon2PasswordHasher(Argon2PasswordHasher):
time_cost = 2
memory_cost = 19 * 1024 # KiB
parallelism = 1Put it first in PASSWORD_HASHERS and keep the old hashers after it. No migration is needed: Django re-hashes each user at their next successful login. On the sample app, one login took a hash from m=102400,t=2,p=8 to m=19456,t=2,p=1.
One caveat: Django's Argon2 hasher doesn't pad its runtime (its harden_runtime() is empty). Until old hashes are upgraded, a login for an account still on the expensive hash takes longer than one for an unknown email, which costs only the cheaper dummy hash. Rate limiting matters most in that window.
The database fix vs the hashing fix
The same sustained test, 200 logins from 10 clients, for the user with 50 projects:
Original code, default hasher: 124 queries, 11.2 logins/s, median 886 ms.
- Query fix, default hasher: 7 queries, 15.4 logins/s, median 633 ms.
- Query fix, OWASP minimum: 7 queries, 84.5 logins/s, median 106 ms.
Server CPU per login explains both steps. The query fix cut it from 342 to 250 ms (1.37×) and throughput rose 1.38×. The hasher cut it to 45 ms (5.6×) and throughput rose 5.5×. That held because the server was CPU-bound and the database was local and warm. With a remote database, the query fix would be worth more.
The query fix also raised peak memory, from 784 to 910 MiB: requests left the database sooner and overlapped more in the hasher. Speeding up one step can push load onto the next.
What you trade
A cheaper hash makes offline cracking cheaper after a breach. The OWASP minimum is a published baseline, not proof that it suits your accounts, and these benchmarks rank settings by cost, not by security. Whatever you choose, rate-limit login per IP and per account, budget memory per host across every worker and replica, and re-run the benchmark when your hardware changes.
Takeaways
- Benchmark password hashing under concurrency; single requests hide most of the cost.
- Lanes spread a hash across cores but don't reduce its work. A busy server pays the full CPU time.
- Your login ceiling is roughly cores ÷ CPU per login: here, 16 per second with the defaults.
- Budget memory as workers ×
memory_cost, plus idle memory. - Failed logins cost nearly as much as successful ones, so rate limiting is capacity planning.
- Choose the strongest setting that fits your budget, starting from the OWASP floor.
These numbers come from one 4-CPU machine. The method transfers; the exact numbers won't.
The sample app, benchmark scripts, raw results and full methodology are at Git repo.
References: OWASP Password Storage Cheat Sheet, RFC 9106, Django password management.