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