Fail2Ban for Django: Stop Brute-Force Logins on Your VPS
Use Fail2Ban to stop brute-force logins on Django: ban IPs from Nginx logs, catch failed Django admin logins with a signal, and test every filter safely.
Fail2Ban protects a Django site by reading a log of failed logins and banning the IP address behind them at the firewall for a while. For an API login that answers 401, the Nginx access log is enough. The Django admin answers a failed login with a normal 200 page, so for the admin you log failed logins from Django itself and point Fail2Ban at that file.
This guide sets up both, plus the SSH jail every server should have, and shows how to test a filter before you trust it.
Imagine your Django server is a cozy castle. Brave knights (your users) come and go, but naughty robots (bad bots) sometimes knock repeatedly on secret doors (login pages) trying to sneak in. We need Fail2Ban, a friendly guard dog, to watch the castle logs and chase away any robot that knocks too many times!
Django itself does not throttle login attempts. Its security docs suggest a plugin or a web server module for that, and Fail2Ban is the web-server-side guard: it stops the robot before it ever reaches Django.
Never edit jail.conf: package updates overwrite it. Your settings go in jail.local or a file in /etc/fail2ban/jail.d/, and they win over the defaults.
bantime: How long the misbehaver is kicked out.
findtime: Time window to count failures.
maxretry: Number of strikes before the ban.
ignoreip (optional, in [DEFAULT]): Addresses never banned, like 127.0.0.1/8 ::1 and your own office IP, so you can't lock yourself out.
If your server has no /var/log/auth.log (Debian 12 and later ship without rsyslog, so SSH logs go only to the systemd journal), replace the logpath line of [sshd] with backend = systemd.
This tells Fail2Ban: "Whenever you see a 401 Unauthorized on a login POST, count it!"
This works for an API login, like one built with Django REST Framework, that answers a wrong password with 401. It does not catch the Django admin, which answers a wrong password with a normal 200 page. The next section covers the admin.
How do I protect the Django admin login with Fail2Ban?#
The admin tower needs a different spell. A failed admin login and a page view look the same in the Nginx log, so let Django write down every failed login, with the visitor's IP, and let Fail2Ban read that.
Log failed logins from Django. Django sends the user_login_failed signal on every failed authenticate(), the admin's included. Listen for it:
Python
# yourapp/signals.py
import logging
from django.contrib.auth.signals import user_login_failed
from django.dispatch import receiver
logger = logging.getLogger("auth.failed")
@receiver(user_login_failed)
def log_failed_login(sender, credentials, request=None, **kwargs):
if request is None:
return
# Behind Nginx every request comes from 127.0.0.1; Nginx's proxy_params
# sends the real address as X-Real-IP.
ip = request.META.get("HTTP_X_REAL_IP") or request.META.get("REMOTE_ADDR")
logger.warning("login failed from %s", ip)
Import the module in your app's AppConfig.ready() so the receiver is connected. Trust X-Real-IP only when Gunicorn listens on 127.0.0.1, so that Nginx is the only one who can set it.
Send those lines to their own file, in settings.py:
for i in {1..6}; do
curl -s -o /dev/null -w "%{http_code}" \
-X POST https://your-domain.com/auth/login/ \
-H "Content-Type: application/json" \
-d '{"username":"bad","password":"bad"}'done
After 5 "bad" tries, your IP should appear in the banned list!
Test a filter against your real log before you wait for robots. fail2ban-regex reads the log and prints how many lines matched:
nginx-botsearch (blocks bots hunting for admin pages and scripts that answer 404)
nginx-limit-req (stops traffic floods)
Tweak Thresholds: Adjust bantime, findtime, maxretry based on real traffic.
Email Alerts: Use action_mwl to notify you on each ban (it needs a working mail setup on the server).
Per-account lockouts: Fail2Ban bans addresses. A package like django-axes locks an account after repeated failures, even when the guesses come from many addresses. The two work well together.
Yes, but not from the Nginx log alone. A failed admin login returns a normal 200 page, so it looks like any page view. Log failed logins with Django's user_login_failed signal, including the visitor's IP, write them to a file, and point a Fail2Ban filter and jail at that file.
Both, if you can. Fail2Ban blocks an address at the firewall, so a robot stops reaching your app at all. django-axes works inside Django and locks an account after repeated failures, even when the guesses come from many addresses. One protects the server, the other protects each account.
Run sudo fail2ban-client set <jail> unbanip <ip>, for example sudo fail2ban-client set sshd unbanip 203.0.113.7. To see which addresses a jail holds right now, run sudo fail2ban-client status <jail>. Add your own address to ignoreip so it never gets banned again.
Run fail2ban-regex with the log file and the filter file, like sudo fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/django-login.conf. It prints how many lines matched and which ones were missed. Fix the regex until every failed login matches and normal requests do not, then restart Fail2Ban so the jail uses the new filter.
馃帀 You now have a trusty Fail2Ban guard watching your Django castle. No more pesky robots breaking in! Keep learning, keep coding, and enjoy your safe server.
Next step: the guard dog is one part of a production server. My Django and Vue.js VPS deployment guide covers the rest by hand, from SSH keys to Celery. If you'd rather not build it yourself, see deploying Django without Docker: when ox prepares a server you own, it turns on UFW and Fail2Ban's SSH jail for you (a Django login jail stays yours to add, as above), and failed-login warnings your app prints show up in ox's searchable logs.
Updated : added the Django admin setup, filter testing, unbanning, a FAQ, and fixed a jail name.
Deploy Django without Docker on your Ubuntu VPS: gunicorn under systemd, Caddy for HTTPS and PostgreSQL. Every step by hand, then the same deploy with ox.