Protect Django Endpoints With mTLS Client Certificates in Nginx
Require mutual TLS client certificates for Django admin, internal APIs, or partner integrations with Nginx: create a private CA, issue client certificates, configure ssl_verify_client, pass verified identity to Django, and handle revocation and renewal.
Problem statement
Some Django endpoints should be reachable only by known machines or people: the admin for a small staff, an internal API called by another service, or a webhook endpoint used by one partner. Passwords and API keys can leak and be replayed. Mutual TLS (mTLS) requires the client to present a certificate signed by your own certificate authority before Nginx forwards anything to Django. Without a valid certificate, the TLS handshake or the request fails at the proxy.
- Create a private CA and issue client certificates per user or service with OpenSSL or step-ca.
- In Nginx, set
ssl_client_certificate /etc/nginx/mtls/ca.crt;andssl_verify_client on;(oroptionalwith per-location checks). - Forward the verified subject to Django:
proxy_set_header X-Client-Cert-DN $ssl_client_s_dn;only when verification succeeded. - In Django, accept that header only from Nginx and map it to permissions.
- Plan for revocation (CRL with
ssl_crl) and short certificate lifetimes with renewal.
Step-by-step solution
1. Create a CA and client certificates
# CA
openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes \
-keyout ca.key -out ca.crt -subj "/CN=Shop Internal CA"
# client key and CSR
openssl req -newkey rsa:2048 -nodes -keyout alice.key -out alice.csr -subj "/CN=alice/O=staff"
# sign for one year with client auth usage
openssl x509 -req -in alice.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 365 -sha256 \
-extfile <(printf "extendedKeyUsage=clientAuth") -out alice.crt
# bundle for browsers
openssl pkcs12 -export -inkey alice.key -in alice.crt -certfile ca.crt -out alice.p12
Keep ca.key offline or in a secrets manager. For more than a handful of certificates, a small CA such as step-ca or HashiCorp Vault's PKI engine automates issuance and short lifetimes.
2. Configure Nginx
Require certificates for the admin only:
server {
listen 443 ssl;
server_name shop.example.com;
ssl_certificate /etc/letsencrypt/live/shop.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/shop.example.com/privkey.pem;
ssl_client_certificate /etc/nginx/mtls/ca.crt;
ssl_verify_client optional;
ssl_verify_depth 2;
location /admin/ {
if ($ssl_client_verify != SUCCESS) { return 403; }
proxy_set_header X-Client-Cert-DN $ssl_client_s_dn;
proxy_set_header X-Client-Cert-Verify $ssl_client_verify;
proxy_pass http://app;
}
location / {
proxy_set_header X-Client-Cert-DN "";
proxy_pass http://app;
}
}
optional asks browsers for a certificate without failing the handshake, so the public site keeps working while /admin/ checks the result. The location / block clears the header so clients cannot inject it on other paths.
3. Use the identity in Django
Map the verified subject to a staff member or service, trusting the header only because Nginx sets it after verification and strips it elsewhere:
class ClientCertMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
dn = request.META.get("HTTP_X_CLIENT_CERT_DN", "")
verified = request.META.get("HTTP_X_CLIENT_CERT_VERIFY") == "SUCCESS"
request.client_cert_cn = None
if verified and dn:
parts = dict(p.split("=", 1) for p in dn.split(",") if "=" in p)
request.client_cert_cn = parts.get("CN")
return self.get_response(request)
Keep normal Django authentication for the admin too; mTLS is a strong outer gate, not a replacement for user accounts and 2FA.
4. Revocation and renewal
When a laptop is lost or a partner relationship ends, revoke the certificate: maintain a CRL (openssl ca -gencrl) and configure ssl_crl /etc/nginx/mtls/ca.crl;, reloading Nginx when it changes. Short lifetimes (days or weeks, issued automatically by step-ca or Vault) reduce reliance on revocation.
5. Load balancers and CDNs
If TLS terminates at a cloud load balancer or CDN, Nginx never sees the client certificate. Use the load balancer's mTLS feature (AWS ALB mTLS, Cloudflare mTLS) and pass its verified headers through, or terminate TLS on Nginx with TCP passthrough.
Explanation
In mutual TLS, the server sends a certificate request during the handshake, listing the CAs it accepts. The client presents a certificate and proves it holds the private key. Nginx verifies the chain against ssl_client_certificate and exposes the result in variables such as $ssl_client_verify and $ssl_client_s_dn. Because verification happens before any HTTP request reaches Django, unauthenticated clients never touch the application code.
Edge cases / notes
- HTTP/2 and renegotiation. Per-location client certificate prompts rely on
optionalat the server level; TLS renegotiation for specific paths is not available with HTTP/2 or TLS 1.3. - Header spoofing. Always overwrite the identity headers in Nginx for every location that proxies to Django.
- Mobile apps. Distributing client certificates to mobile apps is possible but harder to keep secret than to staff browsers.
Next steps
- Combine with other measures in the Django Admin Security Checklist.
- Prefer a network-level gate? See Restrict Django Admin With Tailscale.
- Harden TLS in general with Nginx TLS Configuration A+ for Django.
FAQ
Does mTLS replace Django login for the admin?
No. Use it as an extra gate before Django's own authentication and 2FA.
Can I require client certificates only for /admin/?
Yes, with ssl_verify_client optional and a check of $ssl_client_verify in that location.
How do I revoke a client certificate?
Add it to a CRL configured with ssl_crl and reload Nginx, or issue short-lived certificates.
Does this work behind a cloud load balancer?
Only if the load balancer passes the TLS connection through or performs mTLS itself and forwards the result.