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.

Banning brute-force IPsFailed loginDjango signalLog lineclient IP, usernamefail2ban filtercounts per IPFirewall bannftables or iptablesfail2ban bans addresses at the firewall once they cross the failure threshold.
  1. Connect to Django's user_login_failed signal and log a line with the client IP to a dedicated file.
  2. Make sure the IP is the real client address (configure the proxy header correctly).
  3. Install fail2ban, add a filter matching that line, and a jail with maxretry, findtime, and bantime.
  4. Use a ban action that fits your firewall (nftables or iptables), and whitelist your own addresses with ignoreip.
  5. Test the filter with fail2ban-regex, then trigger failures and confirm a ban with fail2ban-client status.
  6. 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.

one line per failed login2026-09-25 10:14:03 WARNINGlogin failedip=203.0.113.7user='admin'1231Fixed text the filter matches.2Must be the real client, not the proxy.3Useful for you; ignored by the ban.Log from the user_login_failed signal to a dedicated filedjango-deployment.com
A dedicated file keeps the filter simple and fast.

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
the jail, setting by settingValueMeaningmaxretry8failures allowedfindtime10mwithin this windowbantime✓1hblock durationignoreipoffice and VPN ranges!never ban your own teambantime.increment makes repeat offenders wait longerdjango-deployment.com
fail2ban-client status django-login shows current bans.
  • 8 failures in 10 minutes triggers a one-hour ban. Legitimate users rarely fail that often; bots do.
  • ignoreip lists your office, VPN, and monitoring addresses so you never lock yourself out.
  • Use banaction = iptables-multiport if the server uses iptables. Many distributions set a sensible default in jail.conf.
  • For repeat offenders, the recidive jail 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.
two layers, different strengthsfail2ban✓blocks an IP at the firewall✓cheap: Django never sees it✕blind to distributed attacksIn the app✓per-account lockout (django-axes)✓works across many IPs✓2FA for staffKeep both: fail2ban for noise, the app for targeted attacksdjango-deployment.com
With several app servers, fail2ban bans only on the server that saw the failures.

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

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>.

Learning path · step 9 of 15

Hardening a Django deployment

Keep going