Credential Stuffing in Django with Mutual Tls
Credential Stuffing in Django with Mutual Tls — how this specific combination creates or exposes the vulnerability
Credential stuffing relies on automated login attempts using breached username and password pairs. In a Django application protected by mutual TLS (mTLS), the presence of client certificate authentication changes the attack surface but does not remove risks around the application layer login endpoint.
With mTLS, the web server (e.g., nginx or Envoy) terminates TLS and validates client certificates before forwarding requests to Django. If the server is configured to allow access to the login view based solely on a valid client certificate, it may inadvertently reduce other forms of authentication friction while leaving the account password verification path exposed. Attackers can still target the Django login endpoint with many credentials, especially if the server enforces mTLS at the perimeter but the application does not enforce additional rate limits or suspicious behavior checks.
Consider a setup where mTLS restricts access to a Django app by verifying client certificates, but the login view only checks that a request was presented a valid cert and then relies entirely on Django’s form validation and session handling. If the certificate mapping is too permissive (for example, one cert mapped to many users or weak certificate-to-user mapping), attackers might enumerate valid usernames by observing different server responses (e.g., 200 with ‘invalid password’ vs 403 from mTLS rejection). Once a valid username is discovered, attackers run credential stuffing campaigns against the login URL, leveraging lists of breached credentials to test combinations at scale.
Django’s default authentication backend does not inherently know about mTLS client identities unless explicitly integrated. If you use REMOTE_USER via django.contrib.auth.backends.RemoteUserBackend and rely on the web server to map client certificates to usernames, misconfiguration can lead to unintended authorization outcomes. For example, if the mapping is inconsistent or if fallback authentication (password form) remains the primary path, attackers can bypass certificate-based restrictions by targeting the password login directly, especially when protections like rate limiting are applied at a layer that does not consider combined mTLS and form credentials.
Another subtle exposure arises from logging and observability. When mTLS is enforced at the infrastructure layer, developers might assume traffic is inherently authenticated and reduce logging for login attempts. Reduced visibility can delay detection of ongoing credential stuffing campaigns. Additionally, if session cookies or tokens issued after mTLS-based login lack proper rotation and binding to the certificate context, stolen sessions can be reused independently of the original client certificate, extending the impact of a successful stuffing attack.
Mutual Tls-Specific Remediation in Django — concrete code fixes
To securely combine Django with mutual TLS, treat mTLS as a transport enforcement and still apply robust application-level controls. Never rely on mTLS alone to protect authentication endpoints. Implement strict rate limiting, account lockout, and strong session management in Django, and ensure the web server maps client certificates to identities reliably before forwarding to Django.
Configure your web server to require and validate client certificates, and map them to a username that Django can use. Below is an example using nginx to enforce mTLS and set a custom header that Django will trust only when coming from the proxy:
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /etc/nginx/server.crt;
ssl_certificate_key /etc/nginx/server.key;
ssl_client_certificate /etc/nginx/ca.crt;
ssl_verify_client on;
# Map the client certificate common name to a request header
set $client_cert_serial $ssl_client_i_dn_serial;
proxy_set_header X-SSL-Client-Subject $ssl_client_i_dn;
proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
location / {
proxy_pass http://django_app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Only allow requests with a verified client certificate
if ($ssl_client_verify != SUCCESS) {
return 403;
}
}
}
In Django, create a middleware that extracts the mapped identity from the trusted header and authenticates the request when mTLS is used. Ensure this middleware runs after security middleware and before Django’s authentication middleware. Do not rely on this header when the request originates from outside the trusted proxy:
import logging
from django.utils.deprecation import MiddlewareMixin
from django.contrib.auth import login
from django.contrib.auth.models import User
logger = logging.getLogger(__name__)
class MtlsUserMiddleware(MiddlewareMixin):
"""
Map mTLS-authenticated requests to a Django user when the request
comes from a trusted reverse proxy.
"""
def process_request(self, request):
# Only process when behind a trusted proxy that enforces mTLS
if not request.META.get('HTTP_X_FORWARDED_PROTO') == 'https':
return
cert_subject = request.META.get('HTTP_X_SSL_CLIENT_SUBJECT', '')
if not cert_subject:
return
# Example extraction of CN from '/CN=username/...'
# Use a robust parsing approach for your certificate format
username = self._extract_username_from_subject(cert_subject)
if username:
try:
user = User.objects.get(username=username)
# Authenticate and log the user in for the session
user = authenticate(request, username=user.username)
if user is not None:
login(request, user)
except User.DoesNotExist:
logger.warning('mTLS user not found: %s', username)
def _extract_username_from_subject(self, subject: str) -> str:
# Simple extraction for illustration; prefer a library for robustness
import re
m = re.search(r'/CN=([^,/]+)', subject)
return m.group(1) if m else ''
Apply strong rate limiting at the Django level to mitigate credential stuffing regardless of mTLS. Use Django Ratelimit or a similar library to restrict login attempts per username or per IP+username combination:
from ratelimit.decorators import ratelimit
from django.shortcuts import render, redirect
from django.contrib.auth.forms import AuthenticationForm
@ratelimit(key='body:username', rate='5/m', block=True)
@ratelimit(key='ip', rate='30/m', block=True)
def login_view(request):
if request.method == 'POST':
form = AuthenticationForm(request, data=request.POST)
if form.is_valid():
user = form.get_user()
# Perform additional checks if needed
return redirect('dashboard')
return render(request, 'login.html', {'form': form})
# GET handling
Finally, ensure that any session or token issued after a successful login binds to the mTLS context when feasible. Rotate session identifiers on privilege changes and consider tying session metadata to the certificate fingerprint for sensitive operations. These practices reduce the window of opportunity for credential stuffing even when attackers obtain valid credentials.