IPASIS - IP Reputation and Risk Intelligence API
Blog/Integration Guide

How to Add Bot Detection to Django with Middleware

March 30, 202617 min read
🐍
Django + IPASIS

Django's middleware system is one of the framework's most powerful features — a chain of hooks that process every request and response flowing through your application. It's also the ideal place to add bot detection, because you can intercept malicious traffic before it reaches your views, forms, or API endpoints.

In this guide, you'll build a production-ready bot detection middleware for Django using IPASIS IP intelligence. We'll start with a basic implementation and progressively add caching, view-level decorators, Django REST Framework integration, admin panel protection, and structured logging.

📋

What You'll Build

  • ✅ Django middleware for IP risk scoring
  • ✅ Django cache framework integration (memcached / Redis)
  • ✅ View decorators for route-specific protection
  • ✅ Django REST Framework permission class
  • ✅ Admin panel brute-force protection
  • ✅ Class-based view mixin
  • ✅ Celery async risk scoring for non-blocking checks
  • ✅ Structured security logging with django-structlog
  • ✅ Complete production-ready Django app

Prerequisites

  • • Django 4.2+ (works with Django 5.x too)
  • • Python 3.10+
  • • An IPASIS API key (free tier available)
  • requests or httpx library

Step 1: Basic Bot Detection Middleware

Django middleware classes implement __call__ (or the older process_request / process_response hooks). Our middleware will intercept incoming requests, extract the client IP, query IPASIS for a risk score, and either block or annotate the request.

# botdetection/middleware.py
import requests
from django.http import JsonResponse
from django.conf import settings

IPASIS_API_URL = "https://api.ipasis.com/v1/ip/{ip}"

class BotDetectionMiddleware:
    """
    Django middleware that checks every request against
    IPASIS IP intelligence API and blocks high-risk traffic.
    """

    def __init__(self, get_response):
        self.get_response = get_response
        self.api_key = getattr(settings, 'IPASIS_API_KEY', '')

    def __call__(self, request):
        ip = self.get_client_ip(request)

        try:
            result = requests.get(
                IPASIS_API_URL.format(ip=ip),
                headers={"Authorization": f"Bearer {self.api_key}"},
                timeout=2,
            )
            data = result.json()
        except Exception:
            # Fail open — don't block on API errors
            data = {}

        risk_score = data.get("risk_score", 0)
        request.ip_risk = data  # Attach to request for views

        if risk_score >= 90:
            return JsonResponse(
                {"error": "Request blocked", "reason": "suspicious_ip"},
                status=403,
            )

        return self.get_response(request)

    @staticmethod
    def get_client_ip(request):
        """Extract real client IP, respecting reverse proxies."""
        xff = request.META.get("HTTP_X_FORWARDED_FOR")
        if xff:
            return xff.split(",")[0].strip()
        return request.META.get("REMOTE_ADDR", "0.0.0.0")

Register it in settings.py:

# settings.py
IPASIS_API_KEY = "your-api-key-here"

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "botdetection.middleware.BotDetectionMiddleware",  # Early in chain
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    # ... rest of middleware
]

Place it early in the middleware chain — after SecurityMiddleware but before session/auth middleware. This way you block bots before Django wastes resources loading sessions or authenticating.

Step 2: Add Caching with Django's Cache Framework

Calling an external API on every request is expensive and adds latency. Django's built-in cache framework gives us a clean abstraction — whether you use Redis, Memcached, or local memory, the code stays the same.

# botdetection/middleware.py (updated)
import hashlib
import requests
from django.core.cache import cache
from django.http import JsonResponse
from django.conf import settings

IPASIS_API_URL = "https://api.ipasis.com/v1/ip/{ip}"
CACHE_TTL = 300  # 5 minutes
CACHE_PREFIX = "ipasis_risk:"

class BotDetectionMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response
        self.api_key = getattr(settings, 'IPASIS_API_KEY', '')

    def __call__(self, request):
        ip = self.get_client_ip(request)
        cache_key = f"{CACHE_PREFIX}{ip}"

        # Check cache first
        data = cache.get(cache_key)
        if data is None:
            try:
                result = requests.get(
                    IPASIS_API_URL.format(ip=ip),
                    headers={"Authorization": f"Bearer {self.api_key}"},
                    timeout=2,
                )
                data = result.json()
                cache.set(cache_key, data, CACHE_TTL)
            except Exception:
                data = {}

        risk_score = data.get("risk_score", 0)
        request.ip_risk = data

        if risk_score >= 90:
            return JsonResponse(
                {"error": "Request blocked", "reason": "suspicious_ip"},
                status=403,
            )

        return self.get_response(request)

    @staticmethod
    def get_client_ip(request):
        xff = request.META.get("HTTP_X_FORWARDED_FOR")
        if xff:
            return xff.split(",")[0].strip()
        return request.META.get("REMOTE_ADDR", "0.0.0.0")

Configure Redis as your cache backend for production (Memcached works too):

# settings.py
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": "redis://127.0.0.1:6379/1",
    }
}

With 5-minute cache TTL, a bot sending 1,000 requests/min from one IP generates just 1 API call instead of 60,000. That's a 99.998% reduction in API calls.

Step 3: View Decorators for Route-Specific Protection

Not every endpoint needs the same protection level. A login page needs strict checking; a public blog page doesn't. Django decorators let you apply different risk thresholds per view:

# botdetection/decorators.py
import functools
from django.http import JsonResponse

def require_low_risk(threshold=70, message="Access denied"):
    """
    View decorator that blocks requests above the risk threshold.
    Uses ip_risk data attached by BotDetectionMiddleware.
    """
    def decorator(view_func):
        @functools.wraps(view_func)
        def wrapper(request, *args, **kwargs):
            risk = getattr(request, 'ip_risk', {})
            score = risk.get('risk_score', 0)

            if score >= threshold:
                return JsonResponse(
                    {"error": message, "risk_score": score},
                    status=403,
                )
            return view_func(request, *args, **kwargs)
        return wrapper
    return decorator


def require_human(message="Automated access not allowed"):
    """
    Strict decorator: blocks VPNs, proxies, Tor, and datacenter IPs.
    Use for sensitive endpoints like signup, checkout, password reset.
    """
    def decorator(view_func):
        @functools.wraps(view_func)
        def wrapper(request, *args, **kwargs):
            risk = getattr(request, 'ip_risk', {})

            is_vpn = risk.get('is_vpn', False)
            is_proxy = risk.get('is_proxy', False)
            is_tor = risk.get('is_tor', False)
            is_datacenter = risk.get('is_datacenter', False)
            score = risk.get('risk_score', 0)

            if score >= 80 or is_tor:
                return JsonResponse(
                    {"error": message, "flags": {
                        "vpn": is_vpn, "proxy": is_proxy,
                        "tor": is_tor, "datacenter": is_datacenter,
                    }},
                    status=403,
                )
            return view_func(request, *args, **kwargs)
        return wrapper
    return decorator

Now apply them to your views:

# views.py
from botdetection.decorators import require_low_risk, require_human

# Strict: login and signup
@require_human()
def signup_view(request):
    # Only clean IPs reach here
    ...

@require_low_risk(threshold=75)
def login_view(request):
    # Moderately strict
    ...

# Relaxed: public content
def blog_view(request):
    # No decorator — middleware still annotates request.ip_risk
    # You can log it for analytics without blocking
    ...

Step 4: Django REST Framework Permission Class

If you're building APIs with Django REST Framework, you can integrate bot detection as a permission class — DRF's native way to control access:

# botdetection/permissions.py
from rest_framework.permissions import BasePermission


class IsLowRiskIP(BasePermission):
    """
    DRF permission: denies access if IP risk score exceeds threshold.
    Requires BotDetectionMiddleware to be active.
    """
    message = "Access denied due to suspicious IP activity."

    def has_permission(self, request, view):
        threshold = getattr(view, 'risk_threshold', 80)
        risk = getattr(request, 'ip_risk', {})
        return risk.get('risk_score', 0) < threshold


class IsNotBot(BasePermission):
    """
    DRF permission: blocks known datacenter, proxy, and Tor IPs.
    """
    message = "Automated access is not permitted on this endpoint."

    def has_permission(self, request, view):
        risk = getattr(request, 'ip_risk', {})
        if risk.get('is_tor', False):
            return False
        if risk.get('is_datacenter', False) and risk.get('risk_score', 0) > 60:
            return False
        return risk.get('risk_score', 0) < 85

Apply it to your API views:

# api/views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from botdetection.permissions import IsLowRiskIP, IsNotBot


class SignupAPIView(APIView):
    permission_classes = [IsNotBot]
    risk_threshold = 70  # Custom threshold for this view

    def post(self, request):
        # Only clean traffic reaches here
        return Response({"status": "account_created"})


class PublicDataAPIView(APIView):
    permission_classes = [IsLowRiskIP]
    risk_threshold = 90  # Lenient — only block the worst

    def get(self, request):
        return Response({"data": "public_info"})

Or set it globally in DRF settings for a baseline:

# settings.py
REST_FRAMEWORK = {
    'DEFAULT_PERMISSION_CLASSES': [
        'botdetection.permissions.IsLowRiskIP',
    ],
}

Step 5: Protect the Django Admin Panel

The Django admin panel (/admin/) is a prime target for credential stuffing and brute-force attacks. Add an extra middleware layer specifically for admin routes:

# botdetection/admin_protection.py
import logging
from django.http import JsonResponse
from django.conf import settings

logger = logging.getLogger("botdetection.admin")

class AdminBotProtectionMiddleware:
    """
    Extra-strict bot protection for /admin/ routes.
    Blocks VPNs, Tor, and high-risk IPs from admin login.
    """

    ADMIN_PATHS = ("/admin/", "/admin/login/")

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        if not any(request.path.startswith(p) for p in self.ADMIN_PATHS):
            return self.get_response(request)

        risk = getattr(request, 'ip_risk', {})
        ip = request.META.get("REMOTE_ADDR", "unknown")

        # Strict: block VPN, Tor, datacenter, or score > 60
        if (
            risk.get('is_tor', False)
            or risk.get('is_vpn', False)
            or (risk.get('is_datacenter', False) and risk.get('risk_score', 0) > 40)
            or risk.get('risk_score', 0) >= 60
        ):
            logger.warning(
                "Admin access blocked",
                extra={
                    "ip": ip,
                    "risk_score": risk.get('risk_score'),
                    "is_vpn": risk.get('is_vpn'),
                    "is_tor": risk.get('is_tor'),
                    "path": request.path,
                },
            )
            return JsonResponse(
                {"error": "Admin access denied"},
                status=403,
            )

        # Log all admin access attempts
        logger.info(
            "Admin access allowed",
            extra={"ip": ip, "risk_score": risk.get('risk_score', 0)},
        )

        return self.get_response(request)
# settings.py — add after BotDetectionMiddleware
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "botdetection.middleware.BotDetectionMiddleware",
    "botdetection.admin_protection.AdminBotProtectionMiddleware",
    # ... rest
]

Step 6: Class-Based View Mixin

For Django's class-based views, a mixin provides clean integration:

# botdetection/mixins.py
from django.http import JsonResponse


class BotProtectionMixin:
    """
    Mixin for class-based views. Set risk_threshold on the view
    to control the blocking sensitivity.
    """
    risk_threshold = 80
    block_vpn = False
    block_tor = True
    block_datacenter = False

    def dispatch(self, request, *args, **kwargs):
        risk = getattr(request, 'ip_risk', {})
        score = risk.get('risk_score', 0)

        if score >= self.risk_threshold:
            return self._block_response("risk_score_exceeded")

        if self.block_tor and risk.get('is_tor', False):
            return self._block_response("tor_exit_node")

        if self.block_vpn and risk.get('is_vpn', False):
            return self._block_response("vpn_detected")

        if self.block_datacenter and risk.get('is_datacenter', False):
            return self._block_response("datacenter_ip")

        return super().dispatch(request, *args, **kwargs)

    def _block_response(self, reason):
        return JsonResponse(
            {"error": "Access denied", "reason": reason},
            status=403,
        )


# Usage in views.py:
from django.views.generic import CreateView
from botdetection.mixins import BotProtectionMixin
from .models import Account
from .forms import SignupForm

class SignupView(BotProtectionMixin, CreateView):
    model = Account
    form_class = SignupForm
    risk_threshold = 70
    block_vpn = True
    block_tor = True
    block_datacenter = True

Step 7: Async Risk Scoring with Celery

For high-traffic apps where you want to score IPs without adding latency to the request path, use Celery to process risk checks asynchronously:

# botdetection/tasks.py
import requests
from celery import shared_task
from django.core.cache import cache
from django.conf import settings

IPASIS_API_URL = "https://api.ipasis.com/v1/ip/{ip}"
CACHE_PREFIX = "ipasis_risk:"
CACHE_TTL = 300


@shared_task(bind=True, max_retries=2, default_retry_delay=5)
def score_ip_async(self, ip):
    """
    Score an IP in the background. Result is cached for
    subsequent requests from the same IP.
    """
    cache_key = f"{CACHE_PREFIX}{ip}"

    # Already cached?
    if cache.get(cache_key):
        return

    try:
        result = requests.get(
            IPASIS_API_URL.format(ip=ip),
            headers={
                "Authorization": f"Bearer {settings.IPASIS_API_KEY}"
            },
            timeout=3,
        )
        data = result.json()
        cache.set(cache_key, data, CACHE_TTL)

        # If extremely high risk, add to blocklist
        if data.get('risk_score', 0) >= 95:
            cache.set(f"blocked:{ip}", True, 3600)  # Block for 1 hour

    except requests.RequestException as exc:
        self.retry(exc=exc)

Update the middleware to use the async path for low-priority routes:

# botdetection/middleware.py — async-aware version
from django.core.cache import cache
from django.http import JsonResponse
from botdetection.tasks import score_ip_async

CRITICAL_PATHS = ["/accounts/signup/", "/accounts/login/", "/api/v1/"]

class BotDetectionMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        ip = self.get_client_ip(request)
        cache_key = f"ipasis_risk:{ip}"

        # Check if IP is hard-blocked
        if cache.get(f"blocked:{ip}"):
            return JsonResponse(
                {"error": "Blocked"}, status=403
            )

        data = cache.get(cache_key)

        if data is None:
            if self._is_critical_path(request.path):
                # Synchronous check for critical paths
                data = self._sync_check(ip)
            else:
                # Async check for non-critical paths
                score_ip_async.delay(ip)
                data = {}

        request.ip_risk = data

        if data.get('risk_score', 0) >= 90:
            return JsonResponse(
                {"error": "Request blocked"}, status=403
            )

        return self.get_response(request)

    def _is_critical_path(self, path):
        return any(path.startswith(p) for p in CRITICAL_PATHS)

    def _sync_check(self, ip):
        import requests as http_requests
        from django.conf import settings as django_settings
        cache_key = f"ipasis_risk:{ip}"
        try:
            resp = http_requests.get(
                f"https://api.ipasis.com/v1/ip/{ip}",
                headers={
                    "Authorization": f"Bearer {django_settings.IPASIS_API_KEY}"
                },
                timeout=2,
            )
            data = resp.json()
            cache.set(cache_key, data, 300)
            return data
        except Exception:
            return {}

    @staticmethod
    def get_client_ip(request):
        xff = request.META.get("HTTP_X_FORWARDED_FOR")
        if xff:
            return xff.split(",")[0].strip()
        return request.META.get("REMOTE_ADDR", "0.0.0.0")

This gives you the best of both worlds: synchronous blocking for critical paths (login, signup, API) and non-blocking background scoring for everything else (blog pages, static content). First-time visitors to non-critical pages get zero added latency.

Step 8: Structured Security Logging

Good security requires visibility. Use Python's logging with structured output so your SIEM or log aggregator can parse bot detection events:

# botdetection/logging.py
import json
import logging
import time

logger = logging.getLogger("botdetection")


class SecurityEventLogger:
    """
    Structured security event logger for bot detection.
    Outputs JSON lines compatible with ELK, Datadog, etc.
    """

    @staticmethod
    def log_request(request, action="allow"):
        risk = getattr(request, 'ip_risk', {})
        ip = request.META.get("REMOTE_ADDR", "unknown")

        event = {
            "timestamp": time.time(),
            "event": "bot_detection",
            "action": action,
            "ip": ip,
            "path": request.path,
            "method": request.method,
            "risk_score": risk.get("risk_score", 0),
            "is_vpn": risk.get("is_vpn", False),
            "is_proxy": risk.get("is_proxy", False),
            "is_tor": risk.get("is_tor", False),
            "is_datacenter": risk.get("is_datacenter", False),
            "country": risk.get("country_code", "unknown"),
            "asn": risk.get("asn", "unknown"),
            "isp": risk.get("isp", "unknown"),
            "user_agent": request.META.get(
                "HTTP_USER_AGENT", ""
            )[:200],
        }

        if action == "block":
            logger.warning(json.dumps(event))
        else:
            logger.info(json.dumps(event))

Configure Django logging to capture these events:

# settings.py
LOGGING = {
    "version": 1,
    "handlers": {
        "security_file": {
            "class": "logging.FileHandler",
            "filename": "/var/log/django/bot_detection.log",
            "formatter": "json",
        },
    },
    "formatters": {
        "json": {
            "format": "%(message)s",  # Already JSON
        },
    },
    "loggers": {
        "botdetection": {
            "handlers": ["security_file"],
            "level": "INFO",
        },
    },
}

Step 9: Complete Production Setup

Here's the complete file structure and settings for a production Django app with bot detection:

# Project structure
myproject/
├── botdetection/
│   ├── __init__.py
│   ├── middleware.py         # Core middleware
│   ├── admin_protection.py   # Admin-specific protection
│   ├── decorators.py         # View decorators
│   ├── permissions.py        # DRF permissions
│   ├── mixins.py             # CBV mixin
│   ├── tasks.py              # Celery async tasks
│   └── logging.py            # Structured logging
├── myproject/
│   └── settings.py
└── manage.py
# settings.py — complete bot detection config

IPASIS_API_KEY = env("IPASIS_API_KEY")  # Use django-environ

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "botdetection.middleware.BotDetectionMiddleware",
    "botdetection.admin_protection.AdminBotProtectionMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": env("REDIS_URL", default="redis://127.0.0.1:6379/1"),
    }
}

REST_FRAMEWORK = {
    "DEFAULT_PERMISSION_CLASSES": [
        "botdetection.permissions.IsLowRiskIP",
    ],
}

# Celery (for async scoring)
CELERY_BROKER_URL = env("REDIS_URL", default="redis://127.0.0.1:6379/0")

LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "handlers": {
        "security_file": {
            "class": "logging.handlers.RotatingFileHandler",
            "filename": "/var/log/django/bot_detection.log",
            "maxBytes": 10_000_000,  # 10MB
            "backupCount": 5,
            "formatter": "json",
        },
    },
    "formatters": {
        "json": {"format": "%(message)s"},
    },
    "loggers": {
        "botdetection": {
            "handlers": ["security_file"],
            "level": "INFO",
            "propagate": False,
        },
    },
}

Performance Benchmarks

We tested this setup on a Django 5.1 app running with Gunicorn (4 workers) behind Nginx on a 2-vCPU server:

ScenarioAvg Latencyp99 LatencyAPI Calls/min
No bot detection12ms45ms0
Sync, no cache38ms120ms~600
Sync + Redis cache13ms52ms~10
Async (Celery) + cache12ms46ms~10

With caching enabled, bot detection adds less than 1ms of overhead for cached IPs. The async + Celery approach is virtually zero-impact on the request path.

Bonus: Django-Specific Protection Patterns

Form Submission Protection

Protect Django forms from bot submissions by checking risk in form_valid:

# views.py
from django.views.generic import FormView
from django.contrib import messages

class ContactFormView(FormView):
    template_name = "contact.html"
    form_class = ContactForm

    def form_valid(self, form):
        risk = getattr(self.request, 'ip_risk', {})

        if risk.get('risk_score', 0) >= 70:
            messages.error(
                self.request,
                "Unable to process your request. "
                "Please try again later."
            )
            return self.form_invalid(form)

        # Process the form normally
        form.save()
        return super().form_valid(form)

Django Signals for Real-Time Alerts

# botdetection/signals.py
from django.dispatch import Signal, receiver
from django.core.mail import mail_admins

# Custom signal
high_risk_detected = Signal()  # sender, ip, risk_score, path

@receiver(high_risk_detected)
def notify_admins(sender, ip, risk_score, path, **kwargs):
    if risk_score >= 95:
        mail_admins(
            subject=f"🚨 Critical risk IP: {ip} (score: {risk_score})",
            message=(
                f"A request from {ip} with risk score {risk_score} "
                f"was blocked on {path}."
            ),
        )


# In middleware, emit the signal:
from botdetection.signals import high_risk_detected

if risk_score >= 90:
    high_risk_detected.send(
        sender=self.__class__,
        ip=ip,
        risk_score=risk_score,
        path=request.path,
    )

Gunicorn + Nginx Configuration

# nginx.conf — pass real IP to Django
server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Host $host;
    }
}

# settings.py — trust nginx proxy headers
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
USE_X_FORWARDED_HOST = True

Conclusion

Django's middleware architecture makes it one of the easiest frameworks to add comprehensive bot detection to. With the patterns in this guide, you get:

  • Global protection via middleware — every request is scored
  • Granular control via decorators, DRF permissions, and CBV mixins
  • Admin hardening — brute-force protection on /admin/
  • Zero-latency option — async Celery scoring for non-critical routes
  • Production-ready caching — Redis-backed with Django's cache framework
  • Visibility — structured logging for SIEM integration

The key insight: not every route needs the same protection level. Score globally, enforce locally. Let your login and signup endpoints be strict while your marketing pages stay frictionless.

Protect Your Django App Today

Get your IPASIS API key and start blocking bots in under 5 minutes.

Related Guides