Skip to content
Finowl

Django · Password breach checks

Breached password validator for Django

Django runs every class listed in AUTH_PASSWORD_VALIDATORS through validate_password, so a Finowl validator is checked automatically by UserCreationForm, SetPasswordForm, PasswordChangeForm, the admin and the createsuperuser command. The FinowlBreachedPasswordValidator class implements validate and get_help_text, sends the candidate password to /v1/breaches/password and raises ValidationError with the breach count when the password has been seen before.

The fail_open option, set through OPTIONS in settings, decides what happens when the check cannot complete. With the default of True, a timeout, rate limit or key problem is logged at the right level and the password passes. Set it to False and the user sees a retry message instead. A 400 is ignored on purpose, since it only means the password is empty or longer than 4096 characters.

Steps

  1. 1

    Save the helper as accounts/finowl.py and the validator as accounts/validators.py.

  2. 2

    Add the validator to AUTH_PASSWORD_VALIDATORS after MinimumLengthValidator.

  3. 3

    Set FINOWL_API_KEY wherever Django runs, including the shell you use for createsuperuser.

  4. 4

    Try a well-known weak password in the admin and confirm the breach message appears.

  5. 5

    Choose fail_open based on whether availability or strictness matters more for your signups.

Install
pip install django requests
Breached password validator for Django
# accounts/validators.py
import logging

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

from .finowl import FinowlError, finowl_post  # helper from the Django integration page

logger = logging.getLogger(__name__)


class FinowlBreachedPasswordValidator:
    """Reject passwords found in breach data, as NIST SP 800-63B recommends."""

    def __init__(self, fail_open=True):
        self.fail_open = fail_open

    def validate(self, password, user=None):
        try:
            result = finowl_post("/v1/breaches/password", {"password": password}, timeout=5)
        except FinowlError as exc:
            # Log status and code only. The password never goes near a log line.
            if exc.status == 400:
                return  # empty or over 4096 characters; length validators report it
            if exc.status == 401:
                logger.error("Finowl rejected FINOWL_API_KEY")
            elif exc.status == 429:
                logger.warning("Finowl %s, retry after %ss", exc.code, exc.retry_after)
            else:
                logger.warning("Finowl password check failed: %s %s", exc.status, exc.code)
            if not self.fail_open:
                raise ValidationError(
                    _("We could not check this password right now. Please try again."),
                    code="password_check_unavailable",
                )
            return

        if result["breached"]:
            raise ValidationError(
                _("This password has appeared %(count)s times in data breaches. Choose another."),
                code="password_breached",
                params={"count": f"{result['count']:,}"},
            )

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


# settings.py
# AUTH_PASSWORD_VALIDATORS = [
#     {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
#      "OPTIONS": {"min_length": 12}},
#     {"NAME": "accounts.validators.FinowlBreachedPasswordValidator",
#      "OPTIONS": {"fail_open": True}},
# ]

IN PRODUCTION

Production tips

  • Django runs every validator and collects all errors, so the Finowl check still fires for passwords that fail the length rule. Check length in the form first if you want to save those credits.

  • Enable the validator only in production and staging settings if your test suite creates many users. Otherwise tests make network calls and spend credits on fixture passwords.

  • Keep sensitive_post_parameters on any custom password views. Django's built-in auth views already use it, which keeps passwords out of the error reports emailed to ADMINS.

Each lookup costs 1 credit, with 100 free credits every month. See pricing and error codes.

A FEW GOOD QUESTIONS

Common questions

Breached password validator for Django

Grab an API key, paste the example and run it. 100 free credits every month.