Protect Django Logins with fail2ban
Block brute-force attacks on Django login and admin pages with fail2ban: log failed logins with the real client IP, write a filter and jail, ban at the firewall, and combine with app-level rate limiting.
Problem statement
Bots try thousands of passwords against /admin/login/ and your site's login form. Django's password hashing makes each attempt slow on your side, so a credential-stuffing run costs CPU, fills logs, and eventually guesses weak passwords. Django itself does not lock out IP addresses.
fail2ban watches log files for patterns (here, failed logins), and when one IP address fails too often within a time window, adds a firewall rule that blocks it for a while. This guide makes Django log failed logins in a form fail2ban can read, with the real client IP behind Nginx, and sets up the filter, jail, and verification.
- Connect to Django's
user_login_failedsignal and log a line with the client IP to a dedicated file. - Make sure the IP is the real client address (configure the proxy header correctly).
- Install fail2ban, add a filter matching that line, and a jail with
maxretry,findtime, andbantime. - Use a ban action that fits your firewall (nftables or iptables), and whitelist your own addresses with
ignoreip. - Test the filter with
fail2ban-regex, then trigger failures and confirm a ban withfail2ban-client status. - Keep application-level limits too: fail2ban does not help against attacks spread over many IPs.
Step-by-step solution
1. Log failed logins from Django
# accounts/signals.py
import logging
from django.contrib.auth.signals import user_login_failed
from django.dispatch import receiver
logger = logging.getLogger("security.auth")
def client_ip(request):
return request.META.get("REMOTE_ADDR", "-") if request else "-"
@receiver(user_login_failed)
def log_failed_login(sender, credentials, request=None, **kwargs):
username = (credentials or {}).get("username", "-")
logger.warning("login failed ip=%s user=%r", client_ip(request), username[:100])
Import the module in your app's AppConfig.ready() so the receiver is registered. The Django admin and LoginView both send user_login_failed, with the request, when authentication fails.
Send this logger to its own file:
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"auth": {"format": "%(asctime)s %(message)s", "datefmt": "%Y-%m-%d %H:%M:%S"},
},
"handlers": {
"auth_file": {
"class": "logging.handlers.WatchedFileHandler",
"filename": "/var/log/myapp/auth.log",
"formatter": "auth",
},
},
"loggers": {
"security.auth": {"handlers": ["auth_file"], "level": "WARNING", "propagate": True},
},
}
WatchedFileHandler reopens the file after logrotate moves it. Create the directory with ownership for the app user. Resulting line:
2026-09-25 10:41:07 login failed ip=198.51.100.23 user='admin'
2. Make REMOTE_ADDR the real client
Behind Nginx, REMOTE_ADDR is 127.0.0.1 (or the socket), which would make fail2ban ban nothing or ban your proxy. Fix the client IP first, for example with middleware that trusts X-Forwarded-For only from your proxy, or with Nginx's realip module when another proxy or CDN is in front. If you use a CDN, fail2ban's firewall bans are useless for its traffic (connections come from the CDN's addresses); ban at the CDN instead.
3. Install fail2ban and add a filter
sudo apt install fail2ban
# /etc/fail2ban/filter.d/django-auth.conf
[Definition]
failregex = ^\s*login failed ip=<HOST> user=
ignoreregex =
datepattern = ^%%Y-%%m-%%d %%H:%%M:%%S
<HOST> is fail2ban's placeholder that captures the IP address. Test it against the real log before creating the jail:
sudo fail2ban-regex /var/log/myapp/auth.log /etc/fail2ban/filter.d/django-auth.conf
The output reports how many lines matched and which addresses were found.
4. Create the jail
# /etc/fail2ban/jail.d/django-auth.local
[django-auth]
enabled = true
filter = django-auth
logpath = /var/log/myapp/auth.log
backend = auto
port = http,https
maxretry = 8
findtime = 10m
bantime = 1h
ignoreip = 127.0.0.1/8 ::1 203.0.113.10
banaction = nftables-multiport
- 8 failures in 10 minutes triggers a one-hour ban. Legitimate users rarely fail that often; bots do.
ignoreiplists your office, VPN, and monitoring addresses so you never lock yourself out.- Use
banaction = iptables-multiportif the server uses iptables. Many distributions set a sensible default injail.conf. - For repeat offenders, the
recidivejail bans addresses that are banned repeatedly for much longer.
sudo systemctl restart fail2ban
sudo fail2ban-client status django-auth
5. Test a ban
From a test machine (not on the ignoreip list), fail the login nine times, then:
sudo fail2ban-client status django-auth
sudo nft list ruleset | grep -A3 f2b # or: sudo iptables -L -n | grep f2b
The test IP should appear as banned, and the test machine should no longer reach the site on ports 80 and 443. Unban it:
sudo fail2ban-client set django-auth unbanip 198.51.100.23
6. Keep application-level protections
fail2ban blocks noisy single sources. It does not stop credential stuffing spread across thousands of IPs, and it is a server-level tool that multi-server setups must coordinate. Add, as appropriate:
- rate limiting of login URLs in Nginx (
limit_req) per IP; - account-based throttling or lockout in Django (for example
django-axes), which also catches distributed attacks against one account; - two-factor authentication for staff accounts;
- moving the admin behind a VPN or access proxy.
Verification check:
sudo fail2ban-client status
sudo fail2ban-client status django-auth
tail -n 5 /var/log/myapp/auth.log
Real failed logins appear in the log with public IPs, the jail is active, and bans show up during attacks.
Explanation
fail2ban tails log files, applies regular expressions to each new line, and counts matches per captured host within findtime. When a host reaches maxretry, it runs the ban action, usually adding the address to a firewall set, and removes it after bantime. Django's authentication signal is the reliable place to produce those lines, because it fires for every failed attempt regardless of which login view was used. The quality of the whole setup depends on logging the true client address.
Edge cases / notes
Multiple app servers
Each server's fail2ban sees only its own log. Behind a load balancer, attackers are spread across servers and each sees fewer failures. Centralise bans at the load balancer or firewall layer, or use app-level throttling with a shared store.
IPv6
Attackers can rotate addresses within large IPv6 prefixes. Newer fail2ban versions can ban IPv6 subnets; application-level throttling covers the rest.
When to automate this
Ship the filter, jail, and logging configuration with your provisioning, and alert when the number of bans per hour spikes. A spike is often the first sign of a credential-stuffing campaign.
Next steps
- Harden the admin further with the Django Admin Security Checklist.
- Add per-IP request limits with Rate Limit Django Endpoints with Nginx.
- Make sure logged IPs are correct with Fix Wrong Client IP Addresses in Django Behind a Proxy.
- Review host-level protections with the Linux Server Hardening Checklist for Django.
FAQ
Does Django block brute-force login attempts by default?
No. Django hashes passwords slowly but does not lock out IPs or accounts. Use fail2ban, rate limiting, or a package such as django-axes.
Why does fail2ban never ban anyone for my Django app?
Usually the log contains the proxy's IP instead of the client's, or the filter does not match the log format. Check with fail2ban-regex.
Will fail2ban work behind Cloudflare?
Not with local firewall bans, since connections come from Cloudflare's addresses. Ban at Cloudflare's firewall instead.
How do I unban an IP in fail2ban?
Run fail2ban-client set <jail> unbanip <ip>.