Weak Passwords and Validators: Why Django's Four Defaults Accept a Password Seen 295,389 Times in Breaches

Weak Passwords and Validators: Why Django's Four Defaults Accept a Password Seen 295,389 Times in Breaches

Django Security Series β€” Post 13 | Series III: Authentication & Session
OWASP A07:2025 β€” Authentication Failures | Reading time: ~29 min

πŸ§ͺ Run it yourself. This attack ships as a runnable lab in django-security-lab: three registration views and a victim whose password every default validator approved. One view hashes the password without ever validating it, so the account exists with Password123! β€” and the attacker needs one guess, not a wordlist, to read a flag only she can see. The other two validate, one inline and one in a Form, and refuse that password outright. Reproduce all of it with curl, then run Bandit and Semgrep over the same files and watch which one notices β€” and which one misfires.

I wrote seven custom password validators for PetiΓ§Γ£o Brasil, stacked on top of Django's four built-in ones. Four of my seven are character-class rules: the password must contain an uppercase letter, a lowercase letter, a digit, and a special character. I was pleased with that stack. It felt like diligence β€” eleven validators standing between a user and a bad password, each one a small, testable class with its own error message in two languages.

Then I read the current version of the standard that everyone in this field cites and almost nobody reads to the end. NIST Special Publication 800-63B-4, published on July 31, 2025, says this, in the normative voice the document reserves for hard requirements:

Verifiers and CSPs SHALL NOT impose other composition rules (e.g., requiring mixtures of different character types) for passwords.

Not "should not." Shall not. Revision 3 had said "should not" since 2017; revision 4 promoted it to a prohibition. Four of my seven custom validators are the thing that sentence forbids. I did not write them because I had any evidence they worked. I wrote them because that is what a password policy looks like, and because watching a form reject a weak password felt like doing something.

Let me be precise about which half of that is actually the bug, though. PetiΓ§Γ£o Brasil does call validate_password() before it saves a new password, in the reset form's clean_password1(), with the user object passed in β€” so the flow is wired correctly, and the failure mode this post spends most of its time on is one I avoided. The shape of the policy is the part I got wrong. So there are two different defects here, and this post has to cover both: the developer who never calls the validation API, and the developer who calls it religiously against a rule set that measures the wrong thing.

Then there is the third thing, which is what actually changed my mind. Django's four default validators β€” the block startproject writes into your settings file, the block you have almost certainly never edited β€” accept the password Password123!. That string has been seen 295,389 times in the breach corpora behind Have I Been Pwned. I did not read that number in an article; I queried the API for it while writing this paragraph, and you can reproduce the query in four lines of Python before the end of this post.


The Attack: What It Is and How It Works

There is no payload here, which is why this post sits a little oddly beside the rest of the series. Every other one has had a string at the centre of it β€” an injected quote, a template expression, a traversal sequence, a fixed session id. Weak passwords have no such artifact. The attacker sends an ordinary login request carrying an ordinary password. The vulnerability is not in the request at all. It is that the password was guessable, and it was guessable because your application agreed to store it.

What makes a password guessable is not its length or its character set in the abstract. It is its position in a ranked list. Attackers do not enumerate keyspaces; they enumerate corpora, ordered by observed frequency, because human password choice is wildly concentrated. The canonical list is rockyou.txt: 14,344,392 unique passwords pulled out of a single 2009 breach. It still ships with Kali Linux by default, and sixteen years on it is still the first file most testers reach for. On top of the list sit mangling rules: mechanical transformations applied to every candidate β€” capitalise the first letter, append a digit, substitute a with @, append the current year. A cracking ruleset of a few dozen transforms multiplies a fourteen-million-entry wordlist into a candidate space in the hundreds of millions, and modern hardware walks that in minutes.

This is where composition rules do their damage, and the mechanism runs backwards from what you would expect. A rule that says "your password must contain an uppercase letter, a digit and a symbol" does not push users toward the middle of the keyspace. It pushes them toward a predictable corner of it, because humans satisfy such rules in a small number of stereotyped ways: the capital goes on the first letter, the digits go on the end, the symbol goes after the digits, and the base word is a dictionary word. Password123! is not a random point in a 95-character alphabet raised to the twelfth power. It is password with three transformations applied, all of which ship in every cracking ruleset by default. The composition rule did not add entropy. It narrowed the search.

A guessed password then gets spent in one of three ways, and they map neatly onto three different posts. Online guessing replays candidates against your live login form, which is what rate limiting and lockout (Post 11) exist to slow down. Offline cracking happens after a database is stolen, against the stored hashes, at whatever rate your hashing algorithm and the attacker's GPUs permit β€” password storage gets its own post later in the series. Credential stuffing skips guessing entirely and replays a username/password pair harvested from someone else's breach, which is where reused passwords bite and where multi-factor authentication becomes the answer. All three defences are real and all three are downstream. They make a weak password more expensive to use.

The password validator is the only control in that entire chain that operates upstream, at the moment of choice, on the supply side. It does not make a weak password harder to exploit; it prevents the weak password from existing in your database at all. Getting it wrong is also completely silent. Nothing fails, nothing is logged, no alert fires. A user picks Password123!, your validators wave it through, and the account sits in your admin looking exactly like every other account until the day somebody runs a list against it. The defect ships as data rather than as code. You are never going to find it by reading your views.


Real-World Incidents

RockYou β€” 32.6 million plaintext email addresses and passwords (December 2009); FTC settlement announced March 27, 2012

RockYou built widgets and social games for Facebook and MySpace. In December 2009 an attacker using the handle "igigi" extracted its entire user table through an SQL injection flaw β€” Post 1's vulnerability β€” and found that all 32.6 million email addresses and passwords were stored in clear text. No hash, no salt, no encryption of any kind. The credentials were not merely exposed; they were exposed legibly, which is the difference between a breach that costs an attacker GPU-months and a breach that costs them a download.

I chose this incident over a bigger or more recent one because RockYou is the only breach in this series that did not just leak data β€” it built a weapon that is still in daily use. The dumped passwords, deduplicated, became rockyou.txt. Every password-guessing attack described in the previous section, here and in Post 11, is powered by a list of what 32 million real people actually chose in 2009, and it still works, because the distribution of human password choice has barely moved. 123456 was the single most common password in that dump. It is line one of the 19,640-entry list that ships inside Django today. The blocklist your framework hands you is made out of this breach.

MITRE ATT&CK maps the follow-on activity as T1110 β€” Brute Force. Post 11 covered its stuffing and spraying sub-techniques; this post lives one level down in the same family, at T1110.001 β€” Password Guessing (candidates fired at a live authentication endpoint) and T1110.002 β€” Password Cracking, the offline variant that RockYou made trivial by skipping hashing altogether. The distinction matters operationally: guessing is visible in your logs and answerable with throttling, cracking happens on the attacker's hardware after the fact and is answerable only by what you decided about hashing and password strength before the theft.

The legal outcome is the part I keep returning to, because it is not the part most write-ups quote. The FTC did not charge RockYou primarily over the 32 million adults. It charged the company under the Children's Online Privacy Protection Act, because for roughly two years RockYou had knowingly accepted registrations from children under 13 β€” approximately 179,000 of them β€” collecting email addresses, passwords, birth year, sex, ZIP code and country without verifiable parental consent. The March 27, 2012 settlement required a $250,000 civil penalty on the COPPA counts, a comprehensive data-security programme, and independent third-party security audits every other year for twenty years. On the security side the Commission's finding was that RockYou had failed to maintain "reasonable procedures, such as encryption to protect the confidentiality, security, and integrity of personal information collected from children" β€” while, in the FTC's characterisation, touting its security features to users.

Read that from Brazil and the mapping is close to one-for-one. LGPD Art. 46 requires processing agents to adopt "medidas de seguranΓ§a, tΓ©cnicas e administrativas aptas a proteger os dados pessoais" β€” security measures apt to protect the data. "Apt" is a proportionality standard, not a checklist, and it is assessed against what was reasonably available at the time. A four-line AUTH_PASSWORD_VALIDATORS block that your framework generates for you is about as available as a security measure gets; so is a hashing algorithm you get by doing nothing. And LGPD Art. 14 does to children's data what COPPA did: processing of children's personal data must be carried out in their best interest and, under Β§1, requires "consentimento especΓ­fico e em destaque dado por pelo menos um dos pais ou pelo responsΓ‘vel legal." The RockYou lesson is not "encrypt your passwords." It is that the category of data subject silently raises the standard your engineering has to meet, and that a regulator will read your marketing copy back to you as evidence of what you promised. If your registration form is reachable by a fourteen-year-old β€” and if it is on the public internet, it is β€” then the weak password you accepted is attached to a data subject the law protects more strictly than the ones you designed for.

Sources: FTC β€” Charges That Security Flaws in RockYou Game Site Exposed 32 Million Email Addresses and Passwords (2012) Β· TechCrunch β€” RockYou Hack: From Bad To Worse (2009)


Django's Default Protections

This is one line of Django's own source, and it reframed the whole topic for me:

# django/conf/global_settings.py
AUTH_PASSWORD_VALIDATORS = []

Django's actual default is no password validation at all. The four validators you are thinking of do not live in the framework's defaults; they live in the startproject template, which writes them into your settings.py at the moment you scaffold the project. The distinction matters. Django is not maintaining that protection for you. It handed it over once, as text, in a file you own and edit. Delete the block, or start from a settings module that was not generated by startproject (a cookiecutter, a colleague's boilerplate, a hand-written twelve-factor config), and every password on your site is accepted. There is no warning. manage.py check --deploy does not mention it.

Assuming the block is there, here is what the four validators actually do β€” read from django/contrib/auth/password_validation.py in Django 5.2.17, not from the docs.

UserAttributeSimilarityValidator compares the lowercased password against the user's username, first_name, last_name and email, and against each \W+-delimited part of those values, using difflib.SequenceMatcher.quick_ratio() with a max_similarity threshold of 0.7. Splitting on non-word characters is why an email address protects both thiago and peticaobrasil independently, not just the whole address. It also opens with a short-circuit that is very easy to read straight past:

def validate(self, password, user=None):
    if not user:
        return

No user, no check. Every similarity rule in this validator silently disappears when it is called without a user object β€” and the signature of validate_password(password, user=None) makes omitting it the path of least resistance.

MinimumLengthValidator defaults to min_length=8. The startproject template does not override it. Against NIST SP 800-63B-4, which requires a minimum of 15 characters for a password used as a single factor (and permits 8 only when the password is one factor of a multi-factor scheme), Django's out-of-the-box floor is now roughly half of the current standard for the way most sites actually authenticate.

CommonPasswordValidator loads a gzipped list shipped inside the package and rejects the password if password.lower().strip() appears in it. The docstring says the list contains 20,000 entries. The file that ships with 5.2.17 counts 19,640, all unique, ordered by frequency with 123456 first. I counted it twice, assuming I had made an error the first time. Either number is simultaneously fine and far too small. It is a well-chosen list of the most common passwords ever recorded, and it is about 0.14 % the size of rockyou.txt on its own β€” before you count the fifteen years of breach corpora since.

NumericPasswordValidator is one line: if password.isdigit(). It exists because date-of-birth and phone-number passwords are common enough to deserve their own rule.

So what does that stack accept? I ran all four against a set of candidates, with a user object populated, on Django 5.2.17:

Password Result
123456 rejected β€” too short, too common, entirely numeric
password rejected β€” too common
password123 rejected β€” too common
abcdefgh rejected β€” too common
qwerty123 rejected β€” too common
P@ssw0rd rejected β€” too common
thiago2026 rejected β€” too similar to the username
peticaobrasil rejected β€” too similar to the email address
Password123! accepted
Summer2026! accepted
Tr0ub4dor&3 accepted
correct horse battery staple accepted

The bottom four all pass. Now the same four against Have I Been Pwned's breach corpus, which counts how many times each password has appeared across the breaches HIBP has ingested:

Password Times seen in breaches
Password123! 295,389
Tr0ub4dor&3 3,196
correct horse battery staple 391
Summer2026! 45

That pair of tables is the whole argument of this post. Django's defaults reject P@ssw0rd and accept Password123!, and the second of those is two orders of magnitude more common in real breaches than Tr0ub4dor&3. That one comes from xkcd 936, "Password Strength" β€” the web comic that every argument about passwords eventually cites β€” where it is the bad example: exactly the shape a composition rule produces when it forces someone to add a capital, a digit and a symbol to a dictionary word. The correct horse battery staple row is the one I like best: the passphrase that a generation of engineers learned as the example of a good password has itself been breached 391 times, because it was published as an example and people used it as one. Every one of these numbers is reproducible; the code that produces them is in the testing section below.

One last piece of the default picture: where the validators run automatically, and where they do not. In Django 5.2 the validation call lives in SetPasswordMixin.validate_password_for_user(), which does exactly what you would write yourself β€”

# django/contrib/auth/forms.py
password = self.cleaned_data.get(password_field_name)
if password:
    try:
        password_validation.validate_password(password, user)
    except ValidationError as error:
        self.add_error(password_field_name, error)

β€” and that mixin is used by BaseUserCreationForm (via _post_clean), UserCreationForm, AdminUserCreationForm, SetPasswordForm, PasswordChangeForm and AdminPasswordChangeForm. If your registration, password-change and password-reset flows go through those forms, you are validated and you did not have to do anything.

Everything else is unvalidated:

  • user.set_password(pw) β€” the body is self.password = make_password(raw_password). No validation, by design.
  • User.objects.create_user(...) β€” its _create_user_object() calls make_password(password) directly. No validation.
  • Any DRF serializer, custom Form, GraphQL mutation, management command or admin action that sets a password itself.
  • createsuperuser --noinput. The command does call validate_password() β€” but only inside its if options["interactive"]: branch. The non-interactive path reads DJANGO_SUPERUSER_PASSWORD from the environment and writes it straight through. And even interactively, a failed validation prints the errors and then asks Bypass password validation and create user anyway? [y/N]:. The account with the most privilege in your system is the one Django is most willing to give a bad password to, because it assumes an operator is on the other end.

Vulnerable Pattern: What NOT to Do

Pattern 1 β€” A registration path that sets the password directly

This is the common case, and it almost never looks wrong, because the developer who wrote it was thinking about the user model, not the password. Every custom onboarding flow, invite acceptance, API sign-up and admin "create a user for this client" action ends up here.

# DANGER: nothing in this function consults AUTH_PASSWORD_VALIDATORS.
def register(request):
    username = request.POST["username"]
    password = request.POST["password"]

    user = User(username=username, email=request.POST.get("email", ""))
    user.set_password(password)   # hashes it. Does not validate it.
    user.save()

    return redirect("login")

set_password() hashes correctly. It uses the configured hasher, it salts, it does everything correct password storage asks of it β€” and that competence is exactly what makes the line look finished. Validation is a separate subsystem with a separate entry point, and nothing in the model layer reaches for it. User.objects.create_user(username=..., password=...) has the identical gap for the identical reason. A user registering through this view can choose 1.

Pattern 2 β€” The settings block, deleted or diluted

# DANGER: this is a real configuration I have seen more than once.
AUTH_PASSWORD_VALIDATORS = [
    {
        "NAME": "django.contrib.auth.password_validators.MinimumLengthValidator",
        "OPTIONS": {"min_length": 6},
    },
]

Two failures in five lines. The obvious one is that three validators have been dropped and the length floor lowered to 6 β€” which accepts 123456, the most common password ever recorded, on a site that believes it has a password policy. The subtler one is the import path: password_validators instead of password_validation. Django raises ImproperlyConfigured for that β€” but get_default_password_validators() is decorated with @functools.cache and is only called lazily, on the first password validated. So the misconfiguration does not break startup, does not break manage.py check, and does not break your test suite unless a test actually validates a password. It breaks in production, on your registration form, the first time a real user signs up.

Nobody deletes that block maliciously, which is rather the problem. It goes when someone is consolidating settings, or by a settings module built from scratch for a twelve-factor deployment, or by a developer who hit UserAttributeSimilarityValidator while writing a test fixture, found it annoying, commented it out, and moved on. There is no signal afterwards. The site keeps working; it simply stops saying no.

Pattern 3 β€” Calling the validator, but without the user

This is the pattern that unsettles me most, because it is written by a developer who knew about the validation API and reached for it deliberately.

# DANGER: the call is real. One of the four validators is silently inert.
from django.contrib.auth.password_validation import validate_password

def set_new_password(request):
    password = request.POST["password"]
    validate_password(password)          # <-- no user argument
    request.user.set_password(password)
    request.user.save()

validate_password(password) runs MinimumLengthValidator, CommonPasswordValidator and NumericPasswordValidator normally. UserAttributeSimilarityValidator hits if not user: return and does nothing at all. A user named thiago with the email thiago@peticaobrasil.com.br can set their password to thiago@peticaobrasil.com.br, and this code will accept it, having called the validation API, having raised no error, and having produced a diff that looks like a security improvement in review.

There is a variant that is worse and just as common: calling validate_password() after set_password() and save(), so that the exception β€” when it does fire β€” is raised after the weak password is already committed. And a third: catching ValidationError and logging it instead of re-raising, which turns the validator into a very expensive way to generate log lines.


Secure Implementation: The Django Way

Rule 1 β€” Call validate_password(password, user) in every path that sets a password

The fix for Pattern 1 is one line, in the right place, with both arguments:

from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError

def register(request):
    username = request.POST["username"]
    password = request.POST["password"]

    user = User(username=username, email=request.POST.get("email", ""))

    try:
        # Pass the unsaved user: UserAttributeSimilarityValidator needs the
        # username and email to compare against, and silently no-ops without them.
        validate_password(password, user)
    except ValidationError as exc:
        return render(request, "register.html", {"errors": exc.messages}, status=400)

    user.set_password(password)
    user.save()
    return redirect("login")

Three details in there carry the weight. Validation happens before set_password(), so a rejected password never reaches the hasher or the database. The user object is passed, even though it has not been saved yet β€” the validator only reads attributes, so an unsaved instance works and is exactly what createsuperuser constructs for the same purpose. And the ValidationError is surfaced to the user, with exc.messages giving you the same translated strings the built-in forms show.

The better answer, where it fits, is to not write this at all: subclass BaseUserCreationForm or SetPasswordForm and let the mixin do it. Hand-rolling is for the paths a form does not cover β€” DRF serializers, invite flows, management commands β€” and in those the rule is the same: one call, before the write, with the user.

There is a third parameter worth knowing about, because AUTH_PASSWORD_VALIDATORS is a global setting and occasionally you want a policy that is not. validate_password(password, user, password_validators=[...]) takes an explicit list and ignores the setting entirely, and get_password_validators() builds that list from the same {"NAME": ..., "OPTIONS": ...} dicts you would otherwise put in settings. Reach for it when one flow genuinely needs different rules β€” a staff-only console with a higher floor, say, or a library that ships a policy without imposing it on the project that installs it. The companion lab uses it for the second reason: its 15-character floor would otherwise have applied to every other lab in the repository, several of which deliberately use weak passwords.

Rule 2 β€” Set the length floor the standard actually requires now

AUTH_PASSWORD_VALIDATORS = [
    {
        "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
        # NIST SP 800-63B-4 Β§3.1.1.2: 15 characters SHALL be the minimum for a
        # password used as a single factor. 8 is permitted only behind MFA.
        "OPTIONS": {"min_length": 15},
    },
    # ...
]

Fifteen sounds hostile until you notice what it does to the shape of what users choose: at fifteen characters, mangled dictionary words stop being the path of least resistance and passphrases start being. That is the whole mechanism. You are not asking anyone to memorise more entropy per character; you are making the short, predictable shapes unavailable.

Do not put a ceiling on it. NIST asks verifiers to permit at least 64 characters, and Django costs you nothing to comply: set_password() hashes before storage, so the password column's max_length=128 holds a fixed-width hash regardless of input length. One caveat worth knowing before you go anywhere near hashers β€” if you switch to BCryptPasswordHasher, bcrypt truncates the password at 72 bytes, so a 100-character passphrase is silently only 72 bytes of secret. Django ships BCryptSHA256PasswordHasher specifically to pre-hash around that, and it is the one you want. The default PBKDF2PasswordHasher has no such limit.

Rule 3 β€” Delete the composition rules

This is the one I resisted longest, so I would rather make the argument than just point at the standard.

Composition rules are a proxy measure. They ask "does this password contain the ingredients of a strong password?" when the question that matters is "how far down a ranked candidate list does this password sit?" Those two questions agree on random strings and disagree on everything a human types, because humans satisfy composition requirements through a small, well-known set of transformations that cracking rulesets encode by default. Enforcing the rule tells the attacker which transformations to apply. It is a hint, delivered as a requirement.

The empirical version of that argument is the two tables above. Password123! satisfies every composition rule anyone has ever written, and it has been seen 295,389 times. correct horse battery staple satisfies none of them and has been seen 391 times β€” and even that is only because it was published as an example. Arrange those two facts however you like; the character classes are not the signal.

So drop them, and put the effort into length (Rule 2) and a real blocklist (Rule 4). Two caveats. First, if you operate under a compliance regime that mandates composition rules β€” some sector regulations and many enterprise security questionnaires still do β€” keep them, document that you are meeting an external requirement rather than a security one, and make sure the length floor and blocklist are doing the actual work. Second, do not confuse this with a licence to lower the bar: NIST removes composition rules because it raises the length minimum and mandates breach checking. Dropping one without adopting the others is not modernisation, it is deregulation.

For PetiΓ§Γ£o Brasil the conclusion is less tidy than that. Four of my seven custom validators are the thing the standard forbids, so those should go. The rest of the list β€” 8 characters to 15, a real blocklist β€” I have not done, and the reason is blast radius rather than backlog: a PetiΓ§Γ£o Brasil password lets you create a petition, not sign one. Signatures go through gov.br's PKI, and it is the certificate that makes a signature binding, not the session that reached the form. So I accepted the risk on impact β€” which is a defensible call, and still an uncomfortable sentence to write at the end of a post arguing that this defect ships as data nobody ever looks at.

Rule 4 β€” Check the password against a real breach list

Django's shipped list is 19,640 entries. The breach corpus behind Have I Been Pwned is a different universe of scale, and it is queryable for free, without an API key, without ever transmitting the password β€” via k-anonymity. You SHA-1 the password, send only the first five hex characters of the hash, and get back every suffix in that bucket with its breach count; you do the final comparison locally. The server learns which of roughly a million buckets your password fell into and nothing else.

# labs/post_13_weak_passwords/validators.py
import hashlib
import urllib.error
import urllib.request

from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _

RANGE_URL = "https://api.pwnedpasswords.com/range/{prefix}"


class PwnedPasswordValidator:
    """Reject passwords that appear in the Have I Been Pwned breach corpus.

    Uses the k-anonymity range API: only the first 5 characters of the SHA-1
    hash leave this process. The password itself is never transmitted.
    """

    def __init__(self, threshold=1, timeout=2.0, fail_open=True):
        self.threshold = threshold
        self.timeout = timeout
        self.fail_open = fail_open

    def _fetch(self, prefix):
        """Return the API's raw suffix:count body for one 5-character bucket."""
        request = urllib.request.Request(
            RANGE_URL.format(prefix=prefix),
            # Pads the response with random suffixes so an observer cannot infer
            # the bucket size β€” and therefore narrow the password β€” from the
            # encrypted response length.
            headers={"Add-Padding": "true"},
        )
        with urllib.request.urlopen(request, timeout=self.timeout) as response:
            return response.read().decode("utf-8")

    def validate(self, password, user=None):
        # SHA-1 is not a choice here β€” it is the wire format the range API
        # defines, and it is a lookup key into a public corpus, not a protection.
        # usedforsecurity=False says so, keeps the call working on FIPS builds,
        # and is what stops Bandit's B324 firing on this line.
        digest = hashlib.sha1(
            password.encode("utf-8"), usedforsecurity=False
        ).hexdigest().upper()
        prefix, suffix = digest[:5], digest[5:]

        try:
            body = self._fetch(prefix)
        except (urllib.error.URLError, OSError):
            if self.fail_open:
                return          # availability over policy β€” see the note below
            raise ValidationError(
                _("Could not verify this password against the breach database. "
                  "Please try again."),
                code="pwned_check_unavailable",
            )

        for line in body.splitlines():
            candidate_suffix, _sep, count = line.partition(":")
            if candidate_suffix == suffix and int(count or 0) >= self.threshold:
                raise ValidationError(
                    _("This password has appeared in a known data breach and "
                      "cannot be used."),
                    code="password_pwned",
                )

    def get_help_text(self):
        return _("Your password can't be one that has appeared in a data breach.")

It is standard-library only β€” no httpx, no requests β€” so it drops into any
Django project as-is. There are four decisions in there I want to defend.

Add-Padding: true is not cosmetic. Without it, bucket 49EFE returns 1,950 suffixes in 76,614 bytes, and the response is cacheable (Cache-Control: public, max-age=2678400). With padding it returns 2,112 lines and Cache-Control: no-store. Response size is a side channel β€” an observer who sees an encrypted response of a known length can narrow which bucket you asked for β€” and padding closes it, at the cost of giving up the CDN cache.

fail_open=True is a deliberate, arguable default. If HIBP is unreachable, this validator lets the password through rather than blocking every registration and password reset on your site behind a third party's uptime. For most consumer applications that is the right trade, and for a bank it is the wrong one. I went back and forth on it for longer than I expected to. Make the choice deliberately and write down which way you went; a validator that fails open because nobody thought about the network is a very different artefact from one that fails open on purpose.

threshold=1 rejects anything seen even once. You can raise it β€” threshold=10 rejects only passwords with real prevalence β€” if the strict setting turns away too many legitimate choices. Note what the strict setting does to correct horse battery staple: 391 hits, rejected. That is correct behaviour, and it will still generate a support ticket.

Never send the full hash. The entire security property of this design is the five-character prefix. A "simpler" implementation that posts the whole SHA-1 to a lookup service is not a breach check; it is a password-disclosure endpoint with a friendly name.

Pair it with an enlarged local list for the offline half. CommonPasswordValidator takes a password_list_path, so you can point it at your own file β€” a merged corpus plus the terms specific to your site, which no generic list will ever contain:

{
    "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
    "OPTIONS": {"password_list_path": BASE_DIR / "config" / "common-passwords.txt.gz"},
},

Put your brand name, your product name, your domain, and the current and next year in that file. peticaobrasil2026 is a password somebody will choose, it is in nobody's wordlist, and it is trivially guessable by anyone who has looked at your homepage.


The Analyst's View

The CySA+ vocabulary matters here, because password policy usually gets filed under "compliance" and it does not belong there. A validator stack is a preventive control, and an unusual one: almost every preventive control in this series narrows what an attacker can do to your application, while this one narrows what your users can put into it. Everything else that defends against a guessed password β€” the rate limit in Post 11, the hashing underneath the row, the second factor on the account β€” is downstream of the account already existing with a bad secret, which makes them compensating controls for a weak policy rather than substitutes for one. That ordering matters when you are asked to justify effort: throttling reduces the rate of guessing, MFA reduces the value of a correct guess, and only the validator reduces the number of accounts worth guessing at. Defence in depth means having all three, not picking whichever was easiest to ship.

The second half of the analyst's job here is measurement, and it is the part developers almost never do. You do not have to speculate about how many of your users chose something breached β€” you can find out. Because password hashes are one-way you cannot test your existing users' passwords against a list directly, but you have two honest options: check at the next successful login, where you hold the plaintext for exactly one request and can run it through the breach validator there and flag or force-rotate the account; or take your own hash dump into an isolated environment and crack it with the same tooling an attacker would, which gives you a percentage rather than an opinion. Either produces a number you can put in front of a decision-maker, and the number is usually much worse than anyone expects. That, rather than a policy document, is what gets a 15-character minimum approved.


Catching It Automatically

Testing Your Defence

Three tests, and the second is the one nobody writes.

Prove the path validates. Point it at whichever endpoint sets passwords β€” registration, reset, an invite acceptance, a DRF serializer:

def test_registration_rejects_a_breached_password(self):
    response = self.client.post("/register/", {
        "username": "mallory", "password": "Password123!",
    })
    self.assertEqual(response.status_code, 400)
    self.assertFalse(User.objects.filter(username="mallory").exists())

def test_registration_accepts_a_strong_passphrase(self):
    response = self.client.post("/register/", {
        "username": "mallory", "password": "flat marble kettle horizon",
    })
    self.assertEqual(response.status_code, 201)

The second assertion of the first test is the important one. A view can return 400 for a dozen reasons; only assertFalse(...exists()) proves the password never reached the database.

Prove the policy is still there. This is the test that catches Pattern 2, and almost nobody has it, because it asserts on settings rather than on behaviour:

from django.conf import settings

def test_password_policy_is_configured(self):
    names = {v["NAME"].rsplit(".", 1)[-1] for v in settings.AUTH_PASSWORD_VALIDATORS}
    self.assertIn("MinimumLengthValidator", names)
    self.assertIn("CommonPasswordValidator", names)

    minimum = next(
        v for v in settings.AUTH_PASSWORD_VALIDATORS
        if v["NAME"].endswith("MinimumLengthValidator")
    )
    self.assertGreaterEqual(minimum.get("OPTIONS", {}).get("min_length", 8), 15)

Delete the block, lower the floor, or typo the import path, and this fails in CI instead of in production. It costs nine lines, and it is the only thing standing between you and a settings refactor that quietly disables every validator you have.

And the promise from the introduction. Here is the whole breach check, in four lines you can paste into a shell right now β€” no API key, no account, and your password never leaves the process:

import hashlib, urllib.request
h = hashlib.sha1(b"Password123!", usedforsecurity=False).hexdigest().upper()
body = urllib.request.urlopen("https://api.pwnedpasswords.com/range/" + h[:5]).read().decode()
print(next(int(l.split(":")[1]) for l in body.splitlines() if l.startswith(h[5:])))
# 295389

Run it against your own password before you close this tab. That is the entire argument of this post, and it takes about four seconds.

Scanning It

The lab ships three registration views β€” one that never validates, one that validates inline, one that validates in a Form β€” which turns "does the scanner catch it?" into the sharper question of whether it can tell them apart.

Bandit misses the class completely. Bandit walks the Python AST looking for B-numbered risky constructs, and there is no risky construct here: set_password() is the correct function, called correctly, on the right object. The defect is the absence of a call beside it, and absence has no AST node. Its four findings on the lab are three hardcoded-password hits on the fixtures and one B310 on the secure validator's urlopen. Its loudest finding was on the fix, not on the flaw.

Semgrep needs a word on how it is packaged, because this whole section turns on it. Its rules come from two places and you have to ask for each separately. --config p/<name> pulls a curated pack β€” a hand-picked set the Semgrep team stands behind, and what almost every CI pipeline and every tutorial runs. --config r/<name> pulls from the registry: every published rule for that language or framework, including the ones the packs deliberately leave out, among them a subcategory Semgrep labels audit. Same engine, same lab, different rule set. For this vulnerability that difference is the entire result.

Semgrep's curated packs miss it too:

semgrep scan --config p/django --config p/python --config p/owasp-top-ten labs/post_13_weak_passwords/
# Ran 156 rules on 18 files: 3 findings.

None of the three is about password validation, and here is the part that should bother you: two of them fire on the vulnerable and the secure view identically. A rule that cannot distinguish the bug from the fix carries no signal about this vulnerability at all, however serious its severity label looks in a report.

The audit tier catches it:

semgrep scan --config r/python.django --config r/python labs/post_13_weak_passwords/
# ❯❱ python.django.security.audit.unvalidated-password
#    labs/post_13_weak_passwords/views_vulnerable.py:37   user.set_password(password)

It fires on the vulnerable view and is silent on the inline secure one β€” so this post ships no custom rule. The finding is not "write a detection"; it is "the curated pack you are running excludes this one, so run the audit tier as well."

Why does it exclude it? Not a guess β€” the rule's own published metadata says so: subcategory: [audit], confidence: LOW. The p/* packs deliberately drop audit-subcategory rules, because an audit rule is a list of places to go and read, not a list of defects. Post 8's no-csrf-exempt and Post 2's avoid-mark-safe are missing from p/django for the same reason. A rule can exist for years, be exactly right about your codebase, and never once run.

And the rule earns its low confidence twice over. It also flags the lab's Form-based view β€” the idiomatic Django shape, where a clean_password() method validates and the view writes afterwards β€” because its exclusion patterns only look for validate_password() in the same lexical scope as set_password(). Validation one function call away is invisible to it. So the more idiomatic your Django is, the more likely this rule is to be wrong about it.

Then there is the autofix, which is worse than useless. Semgrep offers:

if django.contrib.auth.password_validation.validate_password($X, user=$MODEL):
    $MODEL.set_password($X)

validate_password() returns None on success and raises on failure. It never returns a truthy value, so that branch is never taken and set_password() never runs. I read that three times convinced I had it backwards, then checked in the container:

validate_password returns: None
  -> branch taken?            False
  -> u.password after autofix: ''
  -> u.has_usable_password(): True

Apply that fix and you have not hardened registration β€” you have stopped setting passwords, and because has_usable_password() only looks for the ! prefix Django uses to mark a password unusable, the accounts are not even flagged as broken. They are simply accounts nobody can ever log into. An autofix is a suggestion from a pattern matcher that has never run your code.

One last absence worth naming, because it is the opposite of the previous post. Post 12's cookie flag was caught by Django's own manage.py check --deploy as security.W012. There is no equivalent for password policy: nothing in django.core.checks.security or django.contrib.auth.checks looks at AUTH_PASSWORD_VALIDATORS at all, so an empty list β€” Django's actual default β€” produces no warning from the framework's own deployment scanner. The nine-line settings test above is the check Django does not ship.

The full captured output, the mutation experiment that proves the rule keys on the validate_password() call rather than on anything incidental, and the reproduction of the broken autofix are all in the lab's scans/ directory.


The uncomfortable thing about this post is how much of it is subtraction. There is no clever validator at the end of it. You delete four of mine, change an 8 to a 15, add a blocklist check, and then go and make sure every path that writes a password actually calls the thing. I spent more effort building a policy that looked rigorous than I would have spent building one that works. What separates them is not sophistication β€” it is whether the rule measures where a password sits in an attacker's ranked list, or how much it annoyed the person choosing it.

The next post takes the other door into the same account. Password reset is a shadow authentication path β€” whoever completes it owns the account, no password required β€” and hand-rolled reset flows reopen every hole Django's token generator was designed to close: tokens that never expire, tokens that survive the password change they authorised, and reset links whose domain an attacker chooses with a Host header.


Further Reading

← Back to the series