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.

mTLS in front of DjangoClient certsigned by your CANginx verifiesssl_verify_clientHeader to Djangoverified subjectDjango authorizesby identityNginx checks the certificate; Django can then trust a verified identity header.
  1. Create a private CA and issue client certificates per user or service with OpenSSL or step-ca.
  2. In Nginx, set ssl_client_certificate /etc/nginx/mtls/ca.crt; and ssl_verify_client on; (or optional with per-location checks).
  3. Forward the verified subject to Django: proxy_set_header X-Client-Cert-DN $ssl_client_s_dn; only when verification succeeded.
  4. In Django, accept that header only from Nginx and map it to permissions.
  5. 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.

mode and effectHandshakeUse foroffno cert requestedpublic sitesoptionalcert requested, not requiredprotect some pathson!fails without certinternal API hostoptional_no_caany cert acceptedapp does checksoptional plus per-location checks protects the admin without breaking the sitedjango-deployment.com
Use a separate host name for machine-to-machine APIs with ssl_verify_client on.

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.

rollout order1Create CA, issue certsone per person2Install .p12 in browsersstaff devices3ssl_verify_client optionallog results first4Enforce 403 on /admin/without valid cert5CRL and renewal processdocumentedLog verification results before enforcing to avoid locking staff outdjango-deployment.com
Keep an emergency access path, such as a VPN, in case the CA setup breaks.

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 optional at 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

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.

Keep going