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 17 min read 10 views

Token authentication proves who is calling your API. It does not prove the request was not tampered with in transit, not replayed an hour later, or not sent by a bot hammering you ten thousand times a second. For machine-to-machine APIs, webhooks, and partner integrations, you need integrity, freshness, and abuse controls layered on top of identity. This tutorial builds that defense in depth on Django, one threat at a time.

The threats beyond authentication

Authentication answers one question — is this caller who they claim to be — and leaves several others wide open. Can an attacker who captured a valid request replay it to repeat the action? Can a man-in-the-middle alter the request body while keeping the auth token intact? Can a single client overwhelm you with volume, deliberately or through a bug? Can someone abuse a public endpoint to enumerate data or brute-force a value? Each of these is a distinct threat with a distinct mitigation, and a hardened API addresses them in layers rather than assuming a bearer token covers everything. Understanding which layer stops which threat is what lets you reason about your real exposure.

Start with transport security

Everything that follows assumes TLS everywhere. A bearer token, an API key, or an HMAC signature sent over plain HTTP is a credential on a postcard — readable and replayable by anyone on the network path. Enforce HTTPS with HSTS, redirect HTTP to HTTPS, and reject API calls that somehow arrive unencrypted. Modern TLS is cheap and automatic with Let's Encrypt, and it is the non-negotiable foundation; the integrity and freshness controls below add protection on top of an encrypted channel, not as a substitute for one.

Per-client rate limiting

Rate limiting is your first line against abuse, whether malicious or accidental. Limit by API key or client identity rather than IP alone, because clients behind NAT share IP addresses and attackers rotate them freely. Use a shared Redis counter so the limit holds across all your gunicorn workers — a per-worker in-memory counter means your real limit is multiplied by your worker count:

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 but allows bursts at the boundary; a sliding window or token-bucket algorithm smooths that out if precise pacing matters. Always return a 429 with a Retry-After header and document your limits, so well-behaved clients back off gracefully instead of retrying into the wall.

HMAC request signing for integrity

A token proves identity but not that the payload arrived unchanged. HMAC signing closes that gap. The client and server share a secret; the client computes an HMAC-SHA256 over the request — body, method, path, and a timestamp — and sends it in a header. The server recomputes the same HMAC and compares. If even one byte of the body changed in transit, the signatures differ and you reject the request:

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):
        raise PermissionDenied("Bad signature")

This gives you tamper-evidence: no intermediary, proxy, or bug can alter the request without invalidating the signature. It is the same mechanism that payment processors and cloud providers use to sign their API calls and webhooks.

Constant-time comparison

Notice the use of hmac.compare_digest rather than a plain ==. This is not a stylistic choice. A naive string comparison returns as soon as it finds a mismatched byte, so its execution time leaks how many leading bytes were correct. An attacker can measure those microscopic timing differences and reconstruct a valid signature byte by byte — a timing attack. Constant-time comparison takes the same amount of time regardless of where the first difference is, closing that side channel. Anywhere you compare a secret or a signature, use a constant-time comparison; this single habit prevents a subtle but real class of vulnerability.

Defeating replay attacks

A correctly signed request, captured off the wire and sent again, is still a valid signed request — unless you bind freshness into it. There are two complementary defenses, and you want both. First, a timestamp window: the client includes a timestamp in the signed payload, and the server rejects anything older than a few minutes, so a captured request expires quickly. Second, a nonce: the client includes a unique value per request, and the server records seen nonces in Redis with a TTL matching the window, rejecting any repeat. Together they mean a replayed request is either too old or a recognized duplicate:

def check_freshness(ts: str, nonce: str):
    if abs(time.time() - float(ts)) > 300:
        raise PermissionDenied("Stale request")
    if not redis.set(f"nonce:{nonce}", 1, nx=True, ex=300):
        raise PermissionDenied("Replay detected")

Mutual TLS for machine identity

For your highest-value internal and partner APIs, mutual TLS authenticates both ends of the connection with certificates. In ordinary TLS only the server presents a certificate; in mTLS the client must present one too, and the server validates it against a trusted certificate authority before the request is allowed through. This means a caller without a valid client certificate cannot even establish a connection, let alone reach your application. Terminate mTLS at nginx, which validates the client cert and passes 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 the X-Client-DN header — set only by nginx and stripped from any inbound request — as the authenticated machine identity. mTLS is heavier to operate (you issue and rotate client certificates), so reserve it for sensitive links where the strong, mutual guarantee justifies the certificate management.

API key management

However clients authenticate, the credentials need a lifecycle. Generate keys with strong randomness, store only a hash of each key (never the key itself, so a database leak does not expose them), and show the full key to the user exactly once at creation. Support multiple active keys per client so they can rotate without downtime — issue a new key, migrate, then revoke the old one — and make revocation instant. Scope keys to the minimum permissions they need, so a leaked read-only key cannot write. Treat API keys with the same seriousness as passwords, because to a machine client that is exactly what they are.

Input validation and output limits

Hardening is not only about who calls and whether the message is intact; it is also about what the message contains. Validate every input strictly against a schema, reject unexpected fields, and bound the size of request bodies so a client cannot exhaust memory with a giant payload. On the way out, paginate and cap response sizes so an endpoint cannot be coaxed into returning your entire database in one call, and avoid leaking internal details — stack traces, database errors, internal IDs — in error responses. An API that is strict about what it accepts and careful about what it returns removes a whole category of abuse and information disclosure.

Securing webhooks you send

When your API calls out to customer webhooks, you are now the client, and your customers face the same verification problem in reverse. Sign your webhook payloads with HMAC and a per-customer secret, include a timestamp, and document how recipients should verify — exactly as Stripe and GitHub do. This lets your customers confirm that a webhook genuinely came from you and was not forged or replayed. Providing this is part of being a trustworthy API: you are giving the other side the tools to apply the same defenses you apply to inbound calls.

Monitoring and anomaly detection

Controls without observation are half a defense. Log every authentication failure, signature mismatch, replay rejection, and rate-limit hit, and watch for patterns: a spike in signature failures may mean a misconfigured client or an attacker probing; a surge in 429s from one key may mean abuse or a runaway integration. Alert on anomalies so you respond to an attack in progress rather than reading about it in a post-mortem. The layered controls in this tutorial each emit a clear signal when they fire, and those signals are some of the best early warnings you have that something is wrong.

CORS and the browser threat model

If browsers call your API directly, Cross-Origin Resource Sharing is part of your security posture, and it is widely misunderstood. CORS does not protect your server — it instructs the browser which origins may read responses, relaxing the same-origin policy. Configure it precisely: allow only the specific front-end origins that need access, never the wildcard with credentials, and be deliberate about which methods and headers you permit. A too-permissive CORS policy lets a malicious site make authenticated requests on your users' behalf and read the results. Pair it with proper CSRF protection for cookie-authenticated endpoints, because CORS and CSRF defend against related but distinct browser-based attacks.

Secrets management

Every control in this tutorial depends on secrets — HMAC keys, API keys, certificate private keys — and a leaked secret defeats the control it protects. Never commit secrets to source control, never bake them into images, and never log them. Load them from environment variables or, better, a dedicated secrets manager that supports rotation and access auditing. Rotate them on a schedule and immediately on any suspected exposure, which is only painless if you designed for multiple valid keys from the start. Treat the secrets themselves as the crown jewels: the strongest signing scheme is worthless if its key sits in a screenshot or a config file in a public repository.

Defending against enumeration

Attackers probe APIs to discover what exists — valid usernames, sequential record IDs, which emails are registered. Two habits blunt this. First, make responses uniform: a login endpoint should return the same error and timing whether the user does not exist or the password was wrong, so it does not leak which emails are registered. Second, avoid sequential integer IDs in public-facing resources; use UUIDs so an attacker cannot simply increment through your records. Combined with rate limiting, these turn enumeration from a quick scrape into an impractical effort. Information disclosure through subtly different responses is a quiet but real vulnerability class.

Audit logging

For any API touching money, personal data, or privileged actions, an audit log is both a security tool and often a compliance requirement. Record who did what, when, and from where — the authenticated identity, the action, the affected resource, the timestamp, and the source. Make the log append-only and tamper-evident, store it separately from the data it describes, and retain it per your compliance obligations. When an incident happens, this log is how you reconstruct what occurred and scope the damage; without it, you are guessing. Build audit logging in from the start, because reconstructing history after the fact is impossible.

Dependency and supply-chain hygiene

Much of your real attack surface is code you did not write — your dependencies. A vulnerable package in your tree is as dangerous as a hole in your own code, and the supply chain has become a favored attack vector. Pin your dependencies, scan them continuously for known vulnerabilities with tools that watch advisory databases, and update promptly when fixes land. Be cautious adding new packages, preferring well-maintained ones with healthy communities. The most carefully hardened API can still be compromised through a single neglected transitive dependency, so make dependency hygiene a routine part of your security practice rather than an afterthought.

Summary

Defense in depth for APIs means addressing each threat with the layer that stops it. TLS everywhere is the foundation. Rate-limit per client through a shared Redis counter to stop abuse and accidental floods. Sign request bodies with HMAC and a constant-time comparison so tampering is detectable and timing attacks are closed. Add a timestamp window plus nonces to kill replays. Use mutual TLS for your most sensitive machine-to-machine links, manage API keys with hashing and instant revocation, validate inputs strictly and cap outputs, sign the webhooks you send, and monitor every control so you see attacks as they happen. No single layer is sufficient, but together they make your API genuinely hard to abuse — which is exactly what a production, money-touching API needs to be.