October 27, 2026
Django 6.1’s Hash Upgrade Runs on Your Login Request
The stronger default is welcome. A returning user’s first password check also has a migration to finish.

By Tinguyen
8 min read
On October 9, 2026, Alex Georgiev published a Django experiment with a small before-and-after detail. A stored password began with pbkdf2_sha256$1200000$. After authentication, it began with pbkdf2_sha256$1500000$.
The person hadn't chosen a new password. The application had rewritten its stored representation. Georgiev's account of the experiment brought me to the change. Django's own implementation explains why it happens.
I keep coming back to the person waiting for that request.
They entered their password and clicked the button. Before the next screen could appear, the application found some maintenance work to do.
I'd want that work done. But its placement changes what I'd accept as a convincing test of the upgrade.
Django's 6.1 release notes raise the default PBKDF2 iteration count from 1,200,000 to 1,500,000. That's 25% more iterations. It doesn't establish that a login endpoint becomes 25% slower.
For an existing account, the first correct password check can include both verification and replacement hashing. Later checks have a different workload.
The decision I'm interested in is small: what evidence would make me comfortable deploying that change? A green login test seems like a reasonable starting point. Following the password field through the request makes it feel incomplete.
Django stores enough information alongside a password hash to verify it later. In the default PBKDF2 format, that includes the algorithm, iteration count, salt, and derived hash. The first entry in PASSWORD_HASHERS identifies the preferred hasher for new storage. Both are described in Django's password storage documentation.
Those details let the application compare the database's existing representation with its current policy.
In the standard user model implementation, check_password() supplies a setter that generates a replacement and saves the password field. The save happens inside the check. Django also clears its internal password-change marker, so this automatic rehash doesn't invoke password-validator change callbacks as though the person had selected a different password. The distinction is visible in the user model source.
That placement matters to me more than the size of the code change.
No operator schedules that particular account's update. The opportunity arrives when someone supplies the password again. Django's existing upgrade mechanism uses that opportunity to bring the stored value into line with the preferred configuration.
This gives the headline a boundary. A custom flow that calls the low-level check_password() helper without supplying a setter won't persist a replacement through that helper. I'd trace the project's actual authentication path before assuming the framework is doing the work.
"Next login" is shorthand, too. The trigger is a correct password verification through the updating path. The standard backend checks whether the account may authenticate afterward, as shown in its implementation. A correct password and a completed login aren't interchangeable outcomes.
For a release review, I'd want one concrete answer: which call in this application can save a new password hash while handling a request?
Then I'd look at the request's starting state.
Django verifies PBKDF2 using the parameters stored with the existing hash. When the password matches and the representation needs updating, the setter runs. The transition therefore includes an old-parameter verification followed by a new-parameter encoding. That sequence is visible in Django's hashing implementation.
For a simplified model, call the old verification cost A and the new encoding cost B. A transitioning request pays roughly A plus B, along with the database save and the rest of authentication. A later request against the updated representation pays for current verification without that particular rewrite.
If hashing cost scaled exactly with iterations, the transition would involve work corresponding to 1.2 million plus 1.5 million iterations. That's 2.7 million: 2.25 times the old single verification, or 1.8 times the new one.
Those ratios are arithmetic. They aren't measured endpoint latency. Database time, application code, scheduling, and contention all sit outside that calculation.
Still, the calculation changes the experiment I'd run.
Timing the new hasher tells me about one operation on one machine. It doesn't tell me which operations a returning account will trigger. Before trusting the timing, I need to know what was in the password field when the request began.
Imagine a staging test that signs in with the same account a hundred times. The first attempt updates it. The remaining ninety-nine exercise an account that has already caught up. An average across the whole run barely represents the transition I meant to examine.
Now imagine recreating the account with Django 6.1 before every run. That sounds careful: clean fixtures, repeatable setup, no leftovers. But the fixture starts with the new work factor. The test never encounters an older hash.
Both tests could pass without answering my rollout question.
I'd keep separate results for accounts carrying the previous work factor and accounts already carrying the new one. I'd also preserve the old population between trials. Otherwise, the benchmark gradually removes the condition it's supposed to measure.
That's the mistake I want the review to catch. The response can look identical while the work underneath changes after the first request.
The awkward part is that people determine the pace.
With a batch job, I can usually choose a processing rate and watch a queue shrink. A login-triggered transition follows whoever arrives with a password to check.
As the founder of Garther, an event-booking platform in Ho Chi Minh City, I find it useful to think about arrivals around a registration opening. This is a planning scenario, not an account of a Garther deployment. A quiet weekday could be followed by many people returning together, including accounts untouched since the release.
A smooth first hour after deployment wouldn't tell me much about that group.
I'd want the rehearsal to vary both the arrival rate and the proportion of accounts still carrying older hashes. Holding traffic steady while changing that proportion would help separate the transition cost from the ordinary cost of authentication. Then I'd increase concurrency under the CPU limits intended for production.
The measurements would include request latency, CPU use, errors, and password-field writes. I'd keep an eye on the slow end of the latency distribution. A reasonable average doesn't tell me whether a smaller group spent much longer waiting.
I haven't run that rehearsal against a production service for this article. There isn't an honest universal millisecond figure I can put in its place.
What I can specify is the comparison that would help me decide. Does the service meet its existing latency target when a plausible burst contains mostly accounts awaiting an update? How much room remains when other application work shares those resources?
That gives the test a useful outcome even if nothing dramatic happens. Enough capacity would support proceeding. A shortfall would give me a concrete reason to adjust capacity or rollout timing and repeat the same scenario.
I'd also annotate the results with the population being tested. If login duration falls as more accounts become current, that doesn't necessarily mean the application became faster. It may mean the requests stopped doing the extra work.
Without that context, the reassuring graph is hard to interpret.
There's another detail I'd put in the same review: what the running application considers the target.
PBKDF2's must_update() checks whether the stored iteration count differs from the configured count. It doesn't only check whether the stored number is smaller. Salt requirements can also trigger replacement. The rule is explicit in the hasher source.
Suppose a database contains PBKDF2 hashes at three million iterations, while the preferred hasher now uses 1.5 million. A correct password check through the updating path can replace the stored representation with the configured value.
That doesn't mean installing Django 6.1 inevitably removes a project's custom hardening. If the preferred custom hasher still specifies the higher work factor, the stock default hasn't become that project's policy.
I'd inspect the effective configuration before claiming either outcome.
This is a believable review gap without inventing an incident. A team remembers raising its password hashing cost. A later release raises Django's default. Those two facts can sound compatible while leaving the actual configuration unexamined.
I'd want the intended target written down beside the observed stored values. If they differ, I want to understand why before the application starts reconciling them through user requests.
The same rule changes how I'd rehearse rollback. If old and new processes prefer different counts, alternating password checks between them can cause repeated rewrites. That's an inference from the mismatch rule, and a scenario to test when versions overlap.
My rollback question would therefore include what the previous version writes after reading a newer hash. Being able to read the database is only part of the behavior I need to understand.
I wouldn't freeze an older work factor just to keep a latency chart familiar. I'd first establish the intended security policy, then measure the resources needed to apply it. Otherwise, a performance adjustment can quietly become a different decision about password storage.
The smallest useful rehearsal starts with a disposable account encoded at the old work factor.
I'd record its algorithm and iteration count, send the real authentication request, reload the record, and inspect those fields again. Then I'd repeat the request and check that the expected representation remains current.
The first observation establishes that the transition happens through this project's backend and user model. The second establishes what follows it. I'd also send an incorrect password and confirm that the stored credential isn't replaced.
That last case needs its own timing result. An unchanged password field doesn't establish that verification did little work. Django includes runtime-hardening behavior for some failed checks, visible in the verification code. I'd avoid folding successful transitions and rejected attempts into one unexplained average.
I wouldn't log raw passwords or complete encoded hashes to observe any of this. Counts grouped by algorithm and work factor would answer the adoption question. For the controlled test, a coarse indicator of whether a rewrite occurred would help explain request timings.
I'd then rebuild the disposable starting population before another transition run. This is one place where reusing fixtures makes the test less repeatable: the first run has already changed them.
The ordinary application suite still matters. I want to know that authentication works, that the expected account is returned, and that the rest of the flow behaves correctly. The targeted rehearsal adds another question: can the application do the transition work within the capacity available?
I'd keep unrelated authentication changes out of this rollout where practical. Replacing a backend, changing algorithms, and changing infrastructure together would give me more possible explanations for every result. Each could be a sensible project. Combining them would need a reason.
The review artifact I'd want is modest: the effective hashing policy, the observed before-and-after state, and separate timings for transitioning and current accounts under a stated load. That's enough to discuss an actual deployment decision.
Some accounts won't return at all.
Django's login-based mechanism leaves those accounts untouched by that mechanism. Its documentation discusses this limitation and describes wrapped hashers for certain upgrades without a login. That example is a separate migration design, not a command that completes this ordinary PBKDF2 iteration change across the database.
I'd avoid writing "all passwords upgraded" in our own release notes just because the new application is running.
The deployment establishes an active policy. The stored population tells me how far its application has progressed. I'd report those separately, and make a deliberate decision about any dormant accounts instead of treating them as a monitoring error.
I also wouldn't introduce a forced password reset solely to make a progress chart reach a hundred percent. A reset creates work for users and needs its own justification.
This is why the automatic behavior appeals to me. The application can improve the stored credential while the password is available, without asking the person to complete another task. The tradeoff is that their request carries the work, at a time chosen by their arrival.
I don't have an outage story to attach to that tradeoff. The implementation and the observed field change are enough to make me revise the test I'd ask for.
For me, this Django upgrade is ready when I understand what a returning user's request will do, including the password-field write that wasn't in my application diff.