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.
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.
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.
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.
A valid signed request, captured and re-sent, is still valid — unless you bind freshness into it. Two defenses, used together:
X-Timestamp is more than ~300 seconds old.X-Nonce per request; store seen nonces in Redis with a TTL matching the window and reject duplicates.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.
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.