FastAPI & Django Security Middleware

This guide is part of the Server & Platform Implementation Guides reference and covers how to attach HTTP security response headers from Python web frameworks. Django ships a built-in SecurityMiddleware that sets a fixed subset of headers from SECURE_* settings; FastAPI and Starlette ship nothing comparable and require an explicit ASGI or BaseHTTPMiddleware class. Both frameworks set headers at the application layer, which behaves differently from a reverse proxy and changes how you verify, deduplicate, and roll back. For the two recurring sub-problems — choosing between Django’s built-ins and a custom class, and adding a Content-Security-Policy — see Django SecurityMiddleware vs custom headers and setting CSP in Django middleware.

Threat Model & Protocol Mechanics

Security headers are a response-side contract: the server emits directives, the browser enforces them. Setting them at the application layer (inside Django or FastAPI) means the framework is the source of truth and the headers travel with every response object the framework produces — including the 404s and 500s it generates. Setting them at the proxy layer (Nginx/Apache) means the edge is the source of truth and the framework’s headers may be stripped or duplicated. The two layers are not interchangeable: the application layer sees authentication state and per-request nonces; the proxy layer sees raw bytes and runs even when the application crashes.

The headers in scope neutralize distinct attack classes. Strict-Transport-Security (HSTS) defeats SSL-stripping man-in-the-middle attacks by forcing the browser to upgrade http:// to https:// before any request leaves the device. Content-Security-Policy constrains which origins can supply scripts, styles, and connections, which is the primary mitigation for reflected and stored cross-site scripting. X-Frame-Options (and its modern successor, the frame-ancestors CSP directive) blocks clickjacking by refusing to render the page inside a hostile frame. Referrer-Policy controls how much of the originating URL leaks in the Referer header on cross-origin navigation, and Permissions-Policy (covered in the same reference) disables browser features such as camera and geolocation at the origin level.

Django’s built-in coverage. django.middleware.security.SecurityMiddleware is the only header middleware Django ships, and it sets a fixed list from settings:

X-Frame-Options is set by a separate middleware, django.middleware.clickjacking.XFrameOptionsMiddleware, driven by the X_FRAME_OPTIONS setting. Critically, Django has no built-in CSP and no built-in Permissions-Policy. A Content-Security-Policy requires either the django-csp package or a custom middleware class — there is no SECURE_CSP_* setting. This boundary is the single most common source of false confidence: manage.py check --deploy passes while the application ships no CSP at all.

FastAPI/Starlette’s model. FastAPI is built on Starlette, an ASGI framework, and ships no security-header middleware whatsoever. You attach headers either through the @app.middleware("http") decorator (a thin wrapper over BaseHTTPMiddleware) or a hand-written pure-ASGI middleware. Because the response is a mutable object passed back up the stack, the middleware that runs last on the response wins any conflict.

Header middleware position in the request/response cycle Request descends through the middleware stack to the view, the response ascends back out, and the header-setting middleware stamps headers on the way up. Client SecurityMiddleware / header middleware (outermost) Session / CSRF / Auth Common / app middleware View / route handler

request in response out

Headers are stamped on the response as it ascends — outermost middleware runs last and wins.

The header-setting middleware sits at the top of the stack: first to see the request, last to touch the response, so its header values take precedence over anything set deeper in.

Directive Syntax & Spec

The table below maps each header to the Django setting (or custom-middleware requirement) and the FastAPI equivalent. Use it as the baseline contract; deviate only with an explicit reason noted in the rightmost column.

Header Django mechanism Baseline value FastAPI mechanism When to deviate
Strict-Transport-Security SECURE_HSTS_SECONDS + _INCLUDE_SUBDOMAINS + _PRELOAD max-age=31536000; includeSubDomains; preload manual response.headers[...] Drop preload until every subdomain is HTTPS-only
X-Content-Type-Options SECURE_CONTENT_TYPE_NOSNIFF = True nosniff manual Never — always nosniff
X-Frame-Options X_FRAME_OPTIONS = 'DENY' (via XFrameOptionsMiddleware) DENY manual SAMEORIGIN if you self-frame
Referrer-Policy SECURE_REFERRER_POLICY strict-origin-when-cross-origin manual no-referrer for high-secrecy admin paths
Content-Security-Policy none built indjango-csp or custom default-src 'self' + nonces manual / route-level Report-only first; tighten incrementally
Permissions-Policy none built in — custom geolocation=(), camera=(), microphone=() manual Allow features your app actually uses

Malformed-syntax gotchas. HSTS max-age is in seconds, not milliseconds; a value of 300000 is 3.5 days, not 5 minutes. Permissions-Policy uses parenthesized allowlists, not the deprecated Feature-Policy 'none' token — geolocation=() disables it, geolocation=* allows all. A CSP with a trailing semicolon and no directive after it is silently ignored in some parsers; never build CSP strings with naive ";".join() over a possibly-empty list.

Platform-Specific Implementation

Django

Configure the built-ins in settings.py, then add a custom middleware only for what Django does not cover (Permissions-Policy, CSP if you are not using django-csp):

# settings.py
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",  # FIRST: outermost, last on response
    "myapp.middleware.ExtraSecurityHeadersMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_SSL_REDIRECT = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
X_FRAME_OPTIONS = "DENY"
# Behind a TLS-terminating proxy, tell Django the original scheme:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

The custom class for the headers Django omits:

# myapp/middleware.py
class ExtraSecurityHeadersMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        response = self.get_response(request)
        response.setdefault("Permissions-Policy", "geolocation=(), camera=(), microphone=()")
        return response

Using setdefault (the MutableHeaders-style guard) means a per-view override is never clobbered. The decision between leaning on the built-ins and writing your own class is detailed in Django SecurityMiddleware vs custom headers; for the CSP-specific path, see setting CSP in Django middleware.

FastAPI/Starlette

FastAPI has no equivalent of SecurityMiddleware, so you own every header. The decorator form is the most direct, and registering it last means it runs outermost (last on the response):

# main.py
from fastapi import FastAPI, Request

app = FastAPI()

@app.middleware("http")
async def add_security_headers(request: Request, call_next):
    response = await call_next(request)
    response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload"
    response.headers["X-Content-Type-Options"] = "nosniff"
    response.headers["X-Frame-Options"] = "DENY"
    response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
    response.headers["Permissions-Policy"] = "geolocation=(), camera=(), microphone=()"
    # CSP with per-request nonces is set in the route where the nonce is generated.
    return response

For higher throughput, prefer a pure-ASGI middleware over BaseHTTPMiddleware, which buffers the response body and has measurable overhead on streaming responses:

# security_asgi.py
class SecurityHeadersMiddleware:
    def __init__(self, app):
        self.app = app

    async def __call__(self, scope, receive, send):
        if scope["type"] != "http":
            await self.app(scope, receive, send)
            return

        async def send_with_headers(message):
            if message["type"] == "http.response.start":
                headers = message.setdefault("headers", [])
                headers.append((b"x-content-type-options", b"nosniff"))
                headers.append((b"x-frame-options", b"DENY"))
                headers.append((b"referrer-policy", b"strict-origin-when-cross-origin"))
                headers.append(
                    (b"strict-transport-security",
                     b"max-age=31536000; includeSubDomains; preload")
                )
            await send(message)

        await self.app(scope, receive, send_with_headers)

# app.add_middleware(SecurityHeadersMiddleware)

The full nonce-aware FastAPI pattern, including exception-handler coverage so headers reach error responses, is in adding security headers in FastAPI middleware.

How a reverse proxy complements the application layer

When Django or FastAPI runs behind Nginx or Apache, decide which layer owns each header and enforce that decision — duplicates are the failure mode. The cleanest split is: application sets headers that depend on request state (CSP nonces, per-user Permissions-Policy); proxy sets the static transport headers (HSTS, X-Content-Type-Options) with the always flag so they attach even when the upstream returns a 502 it never generated.

server {
    listen 443 ssl;
    server_name api.example.com;

    # Strip whatever the app set for the static headers, then re-assert at the edge.
    proxy_hide_header Strict-Transport-Security;
    proxy_hide_header X-Content-Type-Options;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;

        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
        add_header X-Content-Type-Options "nosniff" always;
    }
}

The always flag emits the header on error responses too — without it, Nginx omits add_header on 4xx/5xx, which is exactly when an attacker-triggered error page would otherwise ship unprotected. Apache’s equivalent is Header always set; the proxy-side patterns are in Nginx security headers configuration and Apache .htaccess & VirtualHost hardening.

Verification & Diagnostic Workflows

Inspect the raw response with curl -I. Expected output from a correctly configured Django or FastAPI deployment:

$ curl -sI https://api.example.com/ | grep -iE '(strict-transport|x-content-type|x-frame|referrer-policy|permissions-policy)'
strict-transport-security: max-age=31536000; includeSubDomains; preload
x-content-type-options: nosniff
x-frame-options: DENY
referrer-policy: strict-origin-when-cross-origin
permissions-policy: geolocation=(), camera=(), microphone=()

For Django, run the deploy check, which audits the SECURE_* settings (note that it will not flag a missing CSP, because Django has no CSP setting to check):

$ python manage.py check --deploy
System check identified no issues (0 silenced).

Pin the contract in CI with a pytest test against the framework’s own test client, so a regression fails the build before it reaches a proxy:

# test_security_headers.py  (Django)
from django.test import Client

def test_security_headers_present():
    resp = Client().get("/", secure=True)
    assert resp["X-Content-Type-Options"] == "nosniff"
    assert resp["X-Frame-Options"] == "DENY"
    assert "max-age=31536000" in resp["Strict-Transport-Security"]
    assert resp["Referrer-Policy"] == "strict-origin-when-cross-origin"
# test_security_headers.py  (FastAPI)
from fastapi.testclient import TestClient
from main import app

def test_security_headers_present():
    resp = TestClient(app).get("/")
    assert resp.headers["x-content-type-options"] == "nosniff"
    assert resp.headers["x-frame-options"] == "DENY"
    assert "max-age=31536000" in resp.headers["strict-transport-security"]

The Django HSTS test must pass secure=True (or HSTS will be absent, because SecurityMiddleware only emits it on HTTPS requests) — a common reason the test passes locally over HTTP and fails to assert anything.

Troubleshooting, Misconfigurations & Safe Rollback

Safe rollback. Header middleware changes are reversible without data loss. To roll back: comment out the custom middleware string in MIDDLEWARE (Django) or the add_middleware/decorator registration (FastAPI), run python manage.py check, then reload workers gracefully (systemctl reload gunicorn or kill -HUP <pid>). Re-run the curl -I verification to confirm the baseline built-in headers are still present before routing production traffic — rolling back a custom class must not silently remove X-Frame-Options.

Frequently Asked Questions

Does Django’s SecurityMiddleware set a Content-Security-Policy? No. Django has no built-in CSP support and no SECURE_CSP_* setting. You must add the django-csp package or write a custom middleware class. manage.py check --deploy will pass even with no CSP, so verify it separately with curl -sI ... | grep -i content-security-policy.

Where should SecurityMiddleware go in the MIDDLEWARE list? First. It must be outermost so it sees the request earliest and stamps headers latest on the response. The only middleware you place above it are custom classes whose header values must override SecurityMiddleware’s output.

Why are my FastAPI security headers missing on error responses? BaseHTTPMiddleware only runs for responses that flow back through it; some exception paths short-circuit. Register an @app.exception_handler that injects the same headers, or use a pure-ASGI middleware that wraps send so it stamps every http.response.start message.

Should I set headers in the framework or in Nginx/Apache? Set request-state-dependent headers (CSP nonces, per-user policies) in the framework, and static transport headers (HSTS, X-Content-Type-Options) at the proxy with the always flag so they survive upstream errors. Pick one owner per header and strip the other to avoid duplicates.

Is BaseHTTPMiddleware slower than pure ASGI? Yes, measurably so for streaming and large responses, because BaseHTTPMiddleware buffers the body. For a header-only middleware on high-throughput services, a pure-ASGI class that only intercepts the http.response.start message avoids that buffering.