DH
4 min read

Securing Server-to-Server Calls Between FastAPI and Django with JWTs and mTLS

Layer JWTs and mutual TLS for production-grade service-to-service authentication. Identity at the application layer, trust at transport—the infrastructure judgment senior engineers use.

Securing Server-to-Server Calls Between FastAPI and Django with JWTs and mTLS

Most security tutorials stop at "add a bearer token." Fine for user-facing auth, but when your FastAPI service calls your Django API—or vice versa—you're dealing with a different threat model. No human login. No browser cookie. Two machines talking, and you need to prove both identity and integrity on every request.

After watching service meshes get bolted on painfully after the fact, my recommendation is to layer two complementary mechanisms: JWTs for application-layer identity and mutual TLS (mTLS) for transport-layer trust. Neither alone is sufficient. Together, they're production-grade.


Why Both? The Layered Security Argument

JWTs answer: who is calling and what are they allowed to do? mTLS answers: is this actually the machine I think it is? A stolen JWT used from a compromised network segment is a real attack. mTLS closes that gap by requiring the caller to present a valid client certificate—one your CA issued—before the TLS handshake completes. The JWT then carries claims (service name, scopes, expiry) that the receiving service validates at the application layer.

Belt and suspenders.


Step 1: Set Up a Private Certificate Authority

Generate a private CA instead of per-service self-signed certs. This lets you rotate and revoke cleanly.

# CA key and certificate
openssl genrsa -out ca.key 4096
openssl req -new -x509 -days 3650 -key ca.key \
-out ca.crt -subj "/CN=internal-ca/O=MyOrg"

# FastAPI service certificate
openssl genrsa -out fastapi-service.key 2048
openssl req -new -key fastapi-service.key \
-out fastapi-service.csr -subj "/CN=fastapi-service/O=MyOrg"
openssl x509 -req -days 365 -in fastapi-service.csr \
-CA ca.crt -CAkey ca.key -CAcreateserial \
-out fastapi-service.crt

# Django service certificate
openssl genrsa -out django-service.key 2048
openssl req -new -key django-service.key \
-out django-service.csr -subj "/CN=django-service/O=MyOrg"
openssl x509 -req -days 365 -in django-service.csr \
-CA ca.crt -CAkey ca.key -CAcreateserial \
-out django-service.crt

Store these in AWS Secrets Manager, HashiCorp Vault, or equivalent. Never commit them.


Step 2: Configure Django to Require Client Certificates

Django itself doesn't handle TLS; your WSGI/ASGI server does. With Gunicorn:

# gunicorn.conf.py
bind = "0.0.0.0:8443"
certfile = "/certs/django-service.crt"
keyfile = "/certs/django-service.key"
ca_certs = "/certs/ca.crt"
cert_reqs = 2 # ssl.CERT_REQUIRED

Extract and validate the client cert CN in middleware to confirm the caller's identity:

# middleware.py
class MTLSMiddleware:
def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
cert = request.META.get("SSL_CLIENT_S_DN", "")
if not cert or "fastapi-service" not in cert:
from django.http import JsonResponse
return JsonResponse({"error": "Invalid client certificate"}, status=403)
return self.get_response(request)

Register it in MIDDLEWARE before your auth middleware.


Step 3: Issue and Validate JWTs Between Services

Use RS256 with your CA's signing key. Here's the pattern with python-jose:

FastAPI side (issue before calling Django):

from jose import jwt
from datetime import datetime, timedelta, timezone

PRIVATE_KEY = open("/certs/fastapi-service.key").read()

def generate_service_token() -> str:
now = datetime.now(timezone.utc)
payload = {
"iss": "fastapi-service",
"aud": "django-service",
"sub": "service-account",
"iat": now,
"exp": now + timedelta(minutes=5),
}
return jwt.encode(payload, PRIVATE_KEY, algorithm="RS256")

Make the request with both mTLS and JWT:

import httpx

async def call_django_endpoint(path: str):
token = generate_service_token()
async with httpx.AsyncClient(
cert=("/certs/fastapi-service.crt", "/certs/fastapi-service.key"),
verify="/certs/ca.crt",
) as client:
response = await client.get(
f"https://django-service:8443{path}",
headers={"Authorization": f"Bearer {token}"},
)
response.raise_for_status()
return response.json()

Django side (validate the JWT):

from jose import jwt, JWTError
from django.http import JsonResponse

PUBLIC_KEY = open("/certs/fastapi-service.crt").read()

def validate_service_token(request):
auth = request.META.get("HTTP_AUTHORIZATION", "")
if not auth.startswith("Bearer "):
return None
token = auth.split(" ", 1)[1]
try:
claims = jwt.decode(
token,
PUBLIC_KEY,
algorithms=["RS256"],
audience="django-service",
)
return claims
except JWTError:
return None

Return 401 if validation fails.


Step 4: Token Rotation and Operational Hygiene

A few production gotchas:

  • Clock skew: Allow minor leeway (options={"leeway": 10} in jose), but keep it under 30 seconds.
  • Cert rotation: Automate it. Expiring certs undetected is unnecessary pain. Use renewal jobs or cert-manager in Kubernetes.
  • Short JWT expiry: Five minutes is reasonable for service-to-service. Longer expiry is just poor credential hygiene.
  • Audit logging: Log iss, sub, and request path for every validated call. You'll need it when debugging.

Putting It Together in Docker Compose

services:
fastapi:
volumes:
- ./certs/fastapi-service.crt:/certs/fastapi-service.crt
- ./certs/fastapi-service.key:/certs/fastapi-service.key
- ./certs/ca.crt:/certs/ca.crt

django:
volumes:
- ./certs/django-service.crt:/certs/django-service.crt
- ./certs/django-service.key:/certs/django-service.key
- ./certs/ca.crt:/certs/ca.crt

In production, use ECS secrets or AWS Parameter Store instead of volume mounts.


The Takeaway

mTLS proves the machine. JWTs prove application identity and carry scoped claims. Together they give you defense in depth without a full service mesh—often overkill until you're running dozens of services. Get this pattern solid, automate cert rotation, keep tokens short-lived, and you'll handle 95% of service-to-service security with code you can debug at 2am.

That's the goal: security you can reason about, not just security that looks good in a diagram.

Damian Hodgkiss

Damian Hodgkiss

Senior Staff Engineer at Sumo Group, leading development of AppSumo marketplace. Technical solopreneur with 25+ years of experience building SaaS products.

Creating Freedom

Join me on the journey from engineer to solopreneur. Learn how to build profitable SaaS products while keeping your technical edge.

    Proven strategies

    Learn the counterintuitive ways to find and validate SaaS ideas

    Technical insights

    From choosing tech stacks to building your MVP efficiently

    Founder mindset

    Transform from engineer to entrepreneur with practical steps