Django 6.1's PBKDF2 default change rewrites existing password hashes on next login
Django 6.1 shipped in August with a one-line change to django.contrib.auth: the default PBKDF2 iteration count for password hashing went from 1,200,000 to 1,500,000. That's the kind of release note entry I'd normally ski
Django 6.1 shipped in August with a one-line change to django.contrib.auth: the default PBKDF2 iteration count for password hashing went from 1,200,000 to 1,500,000. That's the kind of release note entry I'd normally skim past. I timed it instead, and then went looking at what else moves when that one number changes. It turned out to move more than the login latency.
Measuring the hash itself
I installed Django 6.1.2 and, separately, 6.0.9 in their own virtualenvs so I could call the hasher directly with each version's default iteration count, rather than trust a changelog line.
>>> from django.contrib.auth.hashers import PBKDF2PasswordHasher
>>> PBKDF2PasswordHasher().iterations # Django 6.0.9
1200000
>>> PBKDF2PasswordHasher().iterations # Django 6.1.2
1500000
I hashed the same password 20 times at each iteration count and timed both encode() (what happens on signup or password change) and verify() (what happens on every login), on a 4-core container with nothing else running. I did this across three separate process runs to make sure one noisy run wasn't deciding the story.
| 1,200,000 iterations (6.0 default) | 1,500,000 iterations (6.1 default) | |
|---|---|---|
| hash, fastest of 20 | 220โ249ms | 276โ340ms |
| hash, mean of 20 | 270โ285ms | 327โ377ms |
| verify, fastest of 20 | 221โ226ms | 291โ350ms |
| verify, mean of 20 | 268โ290ms | 366โ378ms |
The ranges are the spread across the three runs, not noise within one of them. The ratio holds up: 1,500,000 รท 1,200,000 is 1.25, and the fastest times move by roughly that much in every run. A single login now costs somewhere around 50 to 90ms more CPU time than it did under 6.0, depending on how busy the machine is when it happens.
That's a real number, but it's also a single-threaded one, and logins don't happen one at a time on a busy site.
Does it actually bottleneck under load
CPython's hashlib.pbkdf2_hmac, which Django's PBKDF2 hasher calls into, releases the GIL while OpenSSL does the actual work. I hadn't checked that was true for this specific call path, so I ran the same hash concurrently across a ThreadPoolExecutor with 1, 2, 4 and 8 workers, three times each, on the same 4-core box.
workers=1 throughput=2.62-3.03 hashes/s
workers=2 throughput=5.16-5.59 hashes/s
workers=4 throughput=10.52-10.70 hashes/s
workers=8 throughput=10.69-11.03 hashes/s
Throughput scales almost linearly to 4 workers, then flattens dead at 8, exactly where nproc says it should. So the extra 25% of iterations is a real CPU cost, but it's a cost that spreads across cores like any other CPU-bound Python work that drops the GIL. A login-heavy Django deployment pays for this in CPU headroom, not in a single blocked thread per request.
That's the opposite-reading part of the story: if you were worried this change would create a new serialisation point under load, it doesn't. If you were assuming logins are cheap because they're "just a database lookup," they're not, and they're 25% less cheap than before.
What the release note doesn't mention: it rewrites hashes on login
Django has a general mechanism, documented separately from the 6.1 release notes, for upgrading a stored password hash when the current hasher's parameters don't match what's stored. I wanted to see whether that mechanism actually fires for this specific change, rather than assume it does because the docs say it should in general.
I built a user with a password hash made the old way, under 1,200,000 iterations, then called authenticate() once:
stored hash before login: pbkdf2_sha256$1200000$hn9lZ65mCZoSda9vVPcDXV$80+zt+2a2C0...
authenticate() returned: alex
stored hash after login: pbkdf2_sha256$1500000$G36zO5Tx3UMtf9d3BZdCxb$Z6sxyHgwL5Q...
One successful login, no migration command, no explicit set_password() call from my code, and the stored hash is already on 1,500,000 iterations. The actual SQL, captured with query logging on:
SELECT "auth_user"."id", "auth_user"."password", ... FROM "auth_user" WHERE ...
UPDATE "auth_user" SET "password" = 'pbkdf2_sha256$1500000$...' WHERE "auth_user"."id" = 1
A second login with the now-current hash does nothing further; the UPDATE only fires when the stored iteration count disagrees with the live default. For a site upgrading from 6.0 to 6.1, every active user's hash gets quietly strengthened the next time they sign in, with no backfill job required. That part matches what I'd expect from the general mechanism.
The part that contradicts the obvious assumption
The check that decides whether to rewrite a hash isn't "is the stored iteration count lower than current." It's "does the stored iteration count differ from current, at all." I tested what that means for a hash that was already stronger than the new default, the way a security-conscious admin might set one up deliberately via a custom PASSWORD_HASHERS entry.
iterations before login: 3000000
iterations after login: 1500000
A password hash deliberately hardened to 3,000,000 iterations, double the new 6.1 default, gets reduced to 1,500,000 the next time that user logs in. Nothing warns about it. Nothing in the 6.1 release notes mentions this direction of the mechanism at all; they describe the number going up, not what happens to hashes that were already above it. If you've hand-tuned PBKDF2PasswordHasher.iterations upward on your own project for extra margin, upgrading to 6.1 and letting your users log in normally will walk that hardening back down to Django's new stock value, one login at a time, invisibly.
What it refuses, and what it doesn't
I tried to find a floor Django enforces on the iteration count, assuming a framework that just raised its default for security reasons would also guard against someone configuring it absurdly low. There isn't one, beyond basic argument validation:
>>> h = PBKDF2PasswordHasher()
>>> h.encode('password123', h.salt(), -5)
ValueError: iteration value must be greater than 0.
>>> h.iterations = 1
>>> h.encode('password123', h.salt(), 1) # accepted, 0.24ms to hash
'pbkdf2_sha256$1$HUbNLC8fE4XNtbH9grWXB6$dd/fiZtpFQXRnq...'
Negative values raise. One iteration is accepted without complaint, hashing in under a quarter of a millisecond, which is a password hash in name only. There's no system check, no deployment warning, nothing in manage.py check --deploy that looks at PASSWORD_HASHERS iteration counts. The 6.1 bump raises the bar for everyone who never touches the setting, but anyone who does touch it can set it to 1 and nothing in the framework will object.
There's also a smaller, genuinely odd case in the same method, worth quoting because it's easy to trip over by accident. encode() is implemented as iterations = iterations or self.iterations. Pass 0 explicitly, intending to force zero iterations for some test harness, and Python's truthiness rules mean you get the hasher's live default instead, silently:
>>> h = PBKDF2PasswordHasher()
>>> h.iterations
1500000
>>> h.encode('password123', h.salt(), 0).split('$')[1]
'1500000'
0 isn't rejected and isn't honoured. It's discarded, and you get full-strength hashing without being told your argument was ignored.
How you'd actually notice any of this in production
You wouldn't, from the application's own signals. There's no log line, no Django signal, nothing in django.contrib.auth that marks a save as "this was a hasher upgrade" rather than an ordinary password change. The only trace is the UPDATE auth_user SET password = ... statement itself, indistinguishable in the query log from any other password save unless you inspect the value. If you want to track how many users have been migrated onto the new iteration count after a 6.0 to 6.1 upgrade, the only honest way I found was a one-off query against the stored hash strings themselves: password LIKE 'pbkdf2_sha256$1500000$%' against password LIKE 'pbkdf2_sha256$1200000$%', counted separately, rather than anything the framework surfaces for you.
What I got wrong on the way
My first attempt to install Django 6.1 failed outright, and the error told me why in the first line if I'd read it instead of assuming a typo in the package name: Django 6.1 requires Python 3.12 or later, and the default python3 in this environment is 3.11.17. I'd been building the venv with plain python3 -m venv, which silently gave me 3.11, and pip then refused every 6.0 and 6.1 release as a version mismatch rather than a missing package. Switching to python3.12 -m venv fixed it immediately. It's a one-line fix, but it cost me a confused few minutes of assuming the install itself was broken.
Run it yourself
This needs Python 3.12 or later; Django 6.1 won't install under anything older, which is also the mistake above.
python3.12 -m venv venv && ./venv/bin/pip install django==6.1.2
./venv/bin/python -c "
import django
from django.conf import settings
settings.configure()
django.setup()
import time, statistics
from django.contrib.auth.hashers import PBKDF2PasswordHasher
for iters in (1_200_000, 1_500_000):
h = PBKDF2PasswordHasher()
times = []
for _ in range(20):
t0 = time.perf_counter()
h.encode('correct horse battery staple', h.salt(), iters)
times.append(time.perf_counter() - t0)
print(iters, 'min', round(min(times)*1000, 1), 'ms mean', round(statistics.mean(times)*1000, 1), 'ms')
"
To see the rehash-on-login behaviour, including the downgrade case, you need a real user model and database:
./venv/bin/python -c "
import django
from django.conf import settings
settings.configure(
DATABASES={'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:'}},
INSTALLED_APPS=['django.contrib.auth', 'django.contrib.contenttypes'],
AUTH_PASSWORD_VALIDATORS=[], USE_TZ=True,
)
django.setup()
from django.core.management import call_command
call_command('migrate', '--run-syncdb', verbosity=0)
from django.contrib.auth import authenticate, get_user_model
from django.contrib.auth.hashers import PBKDF2PasswordHasher
User = get_user_model()
h = PBKDF2PasswordHasher()
u = User.objects.create(username='alex')
u.password = h.encode('a-real-password', h.salt(), 3_000_000) # deliberately hardened
u.save()
print('before:', u.password.split('\$')[1])
authenticate(username='alex', password='a-real-password')
u.refresh_from_db()
print('after: ', u.password.split('\$')[1])
"
If you're on 6.0 and planning the move to 6.1, check whether anyone on your team has ever raised PBKDF2PasswordHasher.iterations above the stock value, in a custom hasher subclass or a PASSWORD_HASHERS override. If they have, decide whether you're fine with that margin disappearing the first time each of those users logs in after the upgrade, because nothing will ask you first.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes โ full credit and traffic to the original publisher.