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.

KC Ramo · DjangoZen Team Jun 06, 2026 19 min read 408 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.

Limiting before and after authentication

There is an ordering trap in per-client limits. A limit keyed on an identity the caller merely claims — an unverified key ID header, say — lets an attacker exhaust a legitimate customer's quota by sending junk requests carrying that customer's key ID. Apply per-client limits only after the credential or signature has been verified, and shed unauthenticated floods with a coarse per-IP limit at the edge (nginx's limit_req with limit_req_status 429), which is cheap and never touches Python. Keep the edge limit generous; it exists to absorb volumetric noise, while the Redis limit behind authentication carries the per-customer contract.

If you use Django REST Framework's throttles instead of a hand-rolled counter, point the cache at Redis and override get_cache_key so the bucket follows the authenticated client rather than request.user. DRF keeps a sliding log of timestamps per key, which avoids boundary bursts, but its read-modify-write is not atomic and the stored list grows with the limit, so very high limits are better served by a Redis-side token bucket with constant state.

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.get("X-Timestamp", "")
    sent = request.headers.get("X-Signature", "")
    if not ts or not sent:
        raise PermissionDenied("Missing signature headers")
    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.

Signing a canonical request, not just the body

The minimal example signs the timestamp and body only, leaving the method, path, and query string unprotected: a query parameter such as ?account= could be altered freely without breaking the signature. Production schemes sign a canonical request — a deterministic string built from every part of the request that affects its meaning — and send a key ID header so the server knows which secret to use and keys can rotate:

from urllib.parse import urlencode

def canonical(request, ts: str, nonce: str) -> bytes:
    pairs = sorted((k, v) for k, vs in request.GET.lists() for v in vs)
    body_hash = hashlib.sha256(request.body).hexdigest()
    return "\n".join([request.method, request.path, urlencode(pairs),
                      ts, nonce, body_hash]).encode()

Sorting the query pairs removes ambiguity about parameter order, which HTTP clients do not guarantee. Joining with newlines, which cannot appear unescaped in any field, prevents two different requests from producing the same string by shifting a boundary. The nonce sits inside the signature, so an attacker cannot swap in a fresh one to slip a replay past the nonce check. Ship a small client library to partners rather than a prose description: one side signing a re-serialized JSON object while the other hashes the raw bytes on the wire is the most common cause of "the signature is always wrong" tickets.

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):
    try:
        sent_at = int(ts)          # int() rejects "nan"/"inf", which float() would accept
    except ValueError:
        raise PermissionDenied("Bad timestamp")
    if abs(time.time() - sent_at) > 300:
        raise PermissionDenied("Stale request")
    # Call this only after the signature has been verified. TTL = 2 x the
    # window, so a request stamped up to 300s in the future can't be replayed.
    if not redis.set(f"nonce:{nonce}", 1, nx=True, ex=600):
        raise PermissionDenied("Replay detected")

Order of checks and nonce lifetime

The order of these checks is itself a security property. Validate header format and the timestamp window first, because they are cheap. Verify the signature next. Record the nonce last, only after the signature is valid; otherwise anyone can send unsigned requests carrying observed nonces to "burn" them, making legitimate requests fail as replays, and fill Redis with junk at no cost.

Two edge cases are easy to miss. The nonce TTL must cover the whole acceptance window: with a ±300 second check, a request stamped 299 seconds in the future stays acceptable for nearly ten minutes after arrival, so a 300-second nonce reopens a replay gap. Set the TTL to at least twice the window. And parse the timestamp strictly — float() accepts "nan", and any comparison against NaN is false, so it slips past an abs(...) > 300 test:

if not re.fullmatch(r"\d{10}", ts):
    raise PermissionDenied("Malformed timestamp")
if abs(time.time() - int(ts)) > 300:
    raise PermissionDenied("Stale request")
# only after the signature has been verified:
if not redis.set(f"nonce:{key_id}:{nonce}", 1, nx=True, ex=600):
    raise PermissionDenied("Replay detected")

Scoping nonces by key ID prevents collisions between clients and lets you purge one client's state during an incident. A nonce is not an idempotency key, either: a client whose request timed out must retry with a fresh nonce and timestamp, and rely on a separate Idempotency-Key header to avoid creating a duplicate order.

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.

Protecting the proxy-to-Django hop

Trusting X-Client-DN is only safe if nothing but nginx can set it. proxy_set_header replaces any client-supplied header of the same name, but if gunicorn is also reachable directly, anyone who reaches that port can forge the header. Bind the app server to a Unix socket or 127.0.0.1, and forward the verification result explicitly:

ssl_verify_depth 2;
ssl_crl /etc/nginx/tls/client-ca.crl;
proxy_set_header X-Client-Verify $ssl_client_verify;
proxy_pass http://unix:/srv/app/run/gunicorn.sock;

In Django, trust the DN only when X-Client-Verify equals SUCCESS, and map it to a row in your own database so you can attach scopes and disable a client instantly. Use a private CA dedicated to client certificates — with a public CA, any certificate it ever issued would pass. Prefer short-lived certificates issued by automation, and remember that nginx reads the CRL only at startup and reload, so publishing a revocation and reloading nginx belong in the same runbook step. Avoid ssl_verify_client optional on shared hostnames: a view that forgets to check the header is then silently unprotected.

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.

Hashes for keys, encryption for signing secrets

A practical key format is a short, non-secret prefix you can index and show in the dashboard, followed by 32 random bytes from secrets.token_urlsafe; the database stores the prefix and a SHA-256 of the secret. A fast hash is correct here, unlike for passwords: a 256-bit random secret cannot be brute-forced, and a deliberately slow hasher would add latency to every call. A recognizable prefix also lets secret scanners flag keys leaked into commits.

HMAC signing secrets are the exception — the server needs the secret itself to recompute a signature, so it cannot store only a hash. Encrypt signing secrets at rest, ideally with envelope encryption under a key held in a KMS, and decrypt them only in the verifying process. This asymmetry is one reason high-value partners are often moved to mTLS, where the server holds only public material.

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.

Troubleshooting signature failures

When a partner reports that every request fails verification, the cause is almost always a mismatch in what was signed, not an attack. Log a hash of the canonical string on both sides (never the secret), compare, and work down this list:

SymptomLikely causeFix
Every request fails, body hashes differClient re-serializes JSON after signingSerialize once; sign and send the same bytes
Only requests with query strings failParameter order or percent-encoding differsSort pairs; share one encoding function
Fails only behind one gatewayAn intermediary rewrites the body or pathVerify at the first hop you control
Intermittent "stale request"Clock drift, or milliseconds instead of secondsNTP on clients; return server time in the error
Fails right after rotationOld key ID, or a cached revoked credentialOverlap keys; invalidate caches on rotation

Testing and failure modes

Security controls regress quietly: a refactor moves the nonce write above the signature check, or a new view ships with authentication_classes = []. Encode each threat as a test — a valid request succeeds; a tampered body, a stale timestamp, and a repeated nonce each fail; and a request with a bad signature does not consume its nonce. Add a project-wide test that walks the URL resolver and asserts every API view uses your signing authenticator or a reviewed exemption. Exercise mTLS in an integration environment: no certificate fails at the handshake, a revoked one fails after the CRL reload.

Decide in advance what happens when Redis is unavailable. Rate limiting should usually fail open with an alert, since the edge limit still sheds floods and blocking everything turns a cache incident into an outage. Replay protection on money-moving endpoints should fail closed, because accepting unchecked signed writes reopens the replay window exactly when you can least investigate. Give Redis calls a short socket timeout so a degraded instance produces fast errors rather than a pile of blocked workers. The cryptography itself is cheap — HMAC-SHA256 over a JSON body takes microseconds — so the real per-request cost is the credential lookup and Redis round trips.

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.