Security Advanced

Hardening Django APIs: Rate Limiting, HMAC Request Signing, and Mutual TLS

Lock down server-to-server and public APIs. Layer per-client rate limiting, verify request integrity with HMAC signatures, defeat replay attacks with nonces and timestamps, and authenticate machines with mutual TLS.

DjangoZen Team Jun 06, 2026 3 min read 4 views

Token auth proves who is calling. It doesn't prove the request wasn't tampered with in transit, replayed an hour later, or sent by a bot hammering you 10,000 times a second. For machine-to-machine APIs and webhooks, you need integrity, freshness, and abuse controls on top of identity. Here's the layered approach.

Per-client rate limiting

Rate limit by API key or client ID, not just IP — clients behind NAT share IPs, and attackers rotate them. Use a shared Redis counter so the limit holds across all your workers:

def check_rate(client_id, limit=600, window=60):
    key = f"rl:{client_id}:{int(time.time() // window)}"
    n = redis.incr(key)
    if n == 1:
        redis.expire(key, window)
    if n > limit:
        raise Throttled(detail="Rate limit exceeded")

A fixed window is simple; a sliding window or token bucket is smoother if bursts matter. Return 429 with a Retry-After header so well-behaved clients back off.

HMAC request signing

The client and server share a secret. The client signs the request body (plus method, path, and a timestamp) with HMAC-SHA256 and sends the signature in a header. The server recomputes it and compares. If even one byte of the body changed, the signatures won't match — integrity guaranteed.

import hmac, hashlib

def sign(secret: bytes, ts: str, body: bytes) -> str:
    msg = ts.encode() + b"." + body
    return hmac.new(secret, msg, hashlib.sha256).hexdigest()

def verify(request, secret):
    ts = request.headers["X-Timestamp"]
    sent = request.headers["X-Signature"]
    expected = sign(secret, ts, request.body)
    if not hmac.compare_digest(sent, expected):   # constant-time compare
        raise PermissionDenied("Bad signature")

Use hmac.compare_digest, never == — a plain comparison leaks timing information that can be exploited to forge signatures byte by byte.

Defeating replay attacks

A valid signed request, captured and re-sent, is still valid — unless you bind freshness into it. Two defenses, used together:

  • Timestamp window: reject requests whose X-Timestamp is more than ~300 seconds old.
  • Nonce: require a unique X-Nonce per request; store seen nonces in Redis with a TTL matching the window and reject duplicates.

Mutual TLS for machine identity

For high-value internal or partner APIs, mTLS authenticates both ends with certificates. The client presents a cert; the server validates it against a trusted CA before the request ever reaches Django. Terminate it at nginx and pass the verified identity downstream:

ssl_client_certificate /etc/nginx/ca.crt;
ssl_verify_client on;
proxy_set_header X-Client-DN $ssl_client_s_dn;

Django then trusts X-Client-DN (set only by nginx, stripped from inbound) as the authenticated machine identity.

Summary

Defense in depth for APIs: rate-limit per client through shared Redis, sign request bodies with HMAC and a constant-time compare for integrity, add a timestamp window plus nonces to kill replays, and use mTLS for your most sensitive machine-to-machine links. Each layer addresses a distinct threat — together they make your API genuinely hard to abuse.