BuildNot yet confirmed elsewhere1 publisher2 min readPublished
Django 6.1 cuts hardened password hashes down to its new 1.5M-iteration default at login
Django 6.1 raised its default PBKDF2 iteration count from 1.2M to 1.5M, adding about 50 to 90ms of CPU per login in a dev.to benchmark. The same tests show each stored hash rewritten on its owner's next login, including hashes deliberately set stronger than the new default.
The Engineer · Build desk

What happened
- The author timed encode() and verify() 20 times at each iteration count, across three runs on a 4-core container with nothing else running.
- CPython's hashlib.pbkdf2_hmac, the call Django's PBKDF2 hasher makes, releases the GIL while OpenSSL computes the hash.
- One authenticate() call on a user holding a 1.2M-iteration hash issued an UPDATE on auth_user that stored a 1.5M-iteration hash, with no migration or set_password() call.
- A second login with the rewritten hash triggered no further write to the table.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint Absorbing the extra 25% takes more CPU cores, because on the test box threads beyond the four cores added almost no hashing throughput.
- cost Each returning user's first post-upgrade login is the most expensive one they will make, so login spikes right after a 6.1 deploy cost more CPU than steady state does.
- exposure Users whose admins hardened hashes above the 1.5M default on purpose get weaker hashes back from a routine login.
- decision Teams running a raised count have to choose before deploying 6.1 between keeping their own hasher live and accepting 1.5M iterations for every account that logs in.
The 50 to 90ms figure, from a benchmark write-up on dev.to, is single-threaded. It was measured on an otherwise idle 4-core container, and it varied with how busy the machine was [5]. The author read both defaults off PBKDF2PasswordHasher().iterations in 6.0.9 and 6.1.2 before timing anything [2]. The ratio is the portable part. Iterations rose by a factor of 1.25, and the fastest times moved by roughly that much in every run [4]. The milliseconds carry over to another fleet only if its cores compute PBKDF2-SHA256 at about that container's speed.
Concurrent logins are where a new choke point would show up, and the post found none [15]. On the same box, going from one thread to four raised hashing throughput by 3.5 to 4.0 times [16]. Eight threads added almost nothing over four [8]. "A login-heavy Django deployment pays for this in CPU headroom, not in a single blocked thread per request," the author wrote [9].
The write path is front-loaded. In the post's capture, the hash Django stored after that first login carried a new salt along with the new count [10]. So the request checked the password against the stored 1.2M-iteration hash, then ran a full encode at 1.5M. That is about 2.7M iterations in one login, or 2.25 times what a 6.0 login cost [17]. Each user pays it once [11]. After the deploy, the UPDATE count equals the number of distinct users who sign in, one row each [20]. The post does not measure database load from those writes.
Credit where due: upgrading hashes at login is good design. Every active user's hash gets stronger on their next sign-in, and nobody has to write a backfill job [14]. The problem is the comparison. Django asks whether the stored iteration count differs from the current default, not whether it is lower [12]. The author tested a hash set to 3,000,000 iterations, the kind a security-conscious admin might configure through a custom PASSWORD_HASHERS entry. One login left it at 1,500,000 [13]. Hashing time tracked the iteration count in the post's timings [4], so that login halved the cost of each offline guess against the hash [18].
The admins this catches are, by the post's own description, the security-conscious ones [13]. Under the same rule, a deployment whose live hasher still sets 3,000,000 iterations would find the stored and live counts equal and leave the hash alone [19]. The downgrade lands on sites where that custom hasher is no longer the live one when 6.1 arrives [19].
What to watch
- Whether the Django project changes the rewrite check to fire only on a lower stored count, or documents the downgrade in the 6.1 release notes.
- Login latency and auth_user write rates from production sites in the first days after a 6.1 deploy, set against the 4-core container figures.
Clarity's read
What the record supports and how the coverage leans. The claims behind it follow.
Reality
- Evidence58
- Adoption
- Insufficient
- Hype gap+20
- Incentives
- Insufficient
- Confidence55
Claim ledger
Ranked by verification strength, evidence, and original report placement.
- [1]
Django 6.1 shipped in August with a change to django.contrib.auth raising the default PBKDF2 iteration count for password hashing from 1,200,000 to 1,500,000.
- [2]
Calling PBKDF2PasswordHasher().iterations returned 1200000 under Django 6.0.9 and 1500000 under Django 6.1.2, installed in separate virtualenvs.
- [3]
The author hashed the same password 20 times at each iteration count, timing encode() and verify(), on a 4-core container with nothing else running, across three separate process runs.
- [4]
1,500,000 divided by 1,200,000 is 1.25, and the fastest times moved by roughly that much in every run.
- [5]
A single login costs around 50 to 90ms more CPU time than under Django 6.0, depending on how busy the machine is; the figure is a single-threaded measurement.
- [6]
CPython's hashlib.pbkdf2_hmac, which Django's PBKDF2 hasher calls into, releases the GIL while OpenSSL does the actual work.
- [7]
Hashing throughput with a ThreadPoolExecutor on the 4-core box: 1 worker 2.62-3.03 hashes/s; 2 workers 5.16-5.59; 4 workers 10.52-10.70; 8 workers 10.69-11.03.
- [8]
Throughput scaled almost linearly to 4 workers, then flattened at 8, matching the machine's core count reported by nproc.
- [9]
"A login-heavy Django deployment pays for this in CPU headroom, not in a single blocked thread per request."
- [10]
For a user with a 1,200,000-iteration hash, one authenticate() call, with no migration command and no explicit set_password() call, issued UPDATE "auth_user" SET "password" with a 1,500,000-iteration hash; the salt field changed from hn9lZ65mCZoSda9vVPcDXV to G36zO5Tx3UMtf9d3BZdCxb.
- [11]
A second login with the now-current hash did nothing further; the UPDATE only fires when the stored iteration count disagrees with the live default.
- [12]
The check that decides whether to rewrite a hash is whether the stored iteration count differs from the current one at all, not whether it is lower.
- [13]
A hash deliberately hardened to 3,000,000 iterations, the way a security-conscious admin might set one up via a custom PASSWORD_HASHERS entry, had 1,500,000 iterations after one login.
- [14]
For a site upgrading from Django 6.0 to 6.1, every active user's hash gets strengthened the next time they sign in, with no backfill job required.
- [15]
The author concluded that the change does not create a new serialisation point under load.
- [16]
Going from one worker to four raised hashing throughput by roughly 3.5 to 4.0 times on the test box.
- [17]
The first login after the upgrade does about 2,700,000 PBKDF2 iterations (a verify at the stored 1.2M plus a fresh encode at 1.5M), 2.25 times a Django 6.0 login.
- [18]
Rewriting a 3,000,000-iteration hash to 1,500,000 halves its iteration count, and with hashing time tracking iterations, halves the cost of each offline guess against it.
- [19]
Under the differs-from-live rule, a hash stored at 3,000,000 iterations with a live hasher also set to 3,000,000 would compare equal and not be rewritten; the downgrade applies where the live hasher uses the 1.5M default.
- [20]
After the upgrade, the number of password UPDATEs equals the number of distinct users who sign in, one each.
Sources
1 independent publisher whose own reporting we read for this story.
- dev.toDjango 6.1's PBKDF2 default change rewrites existing password hashes on next login
1 article · October 9, 2026
Topics and entities
Follow any of these and your For You feed starts watching them — no settings page required.