Django Seedkit

Blog · Auth

Stop brute-force logins in Django with django-axes

Django counts no failed logins and limits no request rate. Anyone who finds the login form can try one password after another. django-axes records every attempt and locks out the source after a limit.

uv add django-axes
# config/settings/base.py
INSTALLED_APPS += ["axes"]
MIDDLEWARE += ["axes.middleware.AxesMiddleware"]

AUTHENTICATION_BACKENDS = [
    "axes.backends.AxesBackend",
    "django.contrib.auth.backends.ModelBackend",
]

AXES_FAILURE_LIMIT = 5
AXES_COOLOFF_TIME = 1                                # hours
AXES_LOCKOUT_PARAMETERS = [["ip_address", "username"]]
AXES_RESET_ON_SUCCESS = True

Then run manage.py migrate, because axes ships its own models for the attempt records. Append to INSTALLED_APPS and MIDDLEWARE instead of a new list, so that the entries from the production settings remain. AxesMiddleware goes last, because it wraps the authentication attempts of every other middleware.

AxesBackend after ModelBackend turns the lockout off. The first backend authenticates the request, and axes never sees the attempt. Nothing raises an error, nothing reaches the log, and pip list still shows the package. To find the fault, fail six logins on purpose and watch the seventh attempt succeed.

Lock on both, not either

Note the nested list in AXES_LOCKOUT_PARAMETERS. The inner list means and: axes locks the pair of one IP address and one username. A flat list means or, so either value alone triggers a lockout. A lockout on the username alone is a denial-of-service. An attacker submits five bad passwords for one address, and the owner cannot log in for an hour.

Behind a proxy

Behind Caddy, nginx, or a load balancer, REMOTE_ADDR holds the address of the proxy on every request. All traffic then looks like one client, and the IP half of the lockout has no effect. Install the extra and point axes at the forwarded header.

uv add 'django-axes[ipware]'
AXES_IPWARE_PROXY_COUNT = 1
AXES_IPWARE_META_PRECEDENCE_ORDER = ["HTTP_X_FORWARDED_FOR", "REMOTE_ADDR"]

To check the result, fail a login from a known address and read the axes_accessattempt table. If the table holds the address of the proxy, the header does not reach axes.

Or give the task to an agent. It reads the same setup from a maintained reference file and applies it to the repository:

/django-seedkit add lockout on brute force

The reference holds the full setup, the Redis cache handler, and authenticator-app two-factor: auth-hardening.md · What Django Seedkit is