Adding Security Headers in FastAPI with Middleware

This guide is part of the FastAPI & Django Security Middleware reference. FastAPI runs on Starlette, and Starlette ships no SecurityMiddleware equivalent — unlike Django, there is no built-in that emits HSTS, a Content-Security-Policy, or X-Frame-Options. You set every security header yourself in one middleware that wraps the whole app and stamps headers onto each outgoing response. This page gives both implementations — a BaseHTTPMiddleware class and a faster pure-ASGI middleware — plus the add_middleware ordering rules, an httpx test, and the streaming and reverse-proxy traps specific to ASGI.

Configuration Syntax & Exact Values

The goal is a fixed set of headers on every response. The production baseline:

strict-transport-security: max-age=31536000; includeSubDomains; preload
content-security-policy: default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
x-frame-options: DENY
x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin
permissions-policy: geolocation=(), camera=(), microphone=()

Annotated breakdown of the security-relevant fields:

Header Value Why
Strict-Transport-Security max-age=31536000; includeSubDomains; preload Forces HTTPS for a year across all subdomains. Only emit over TLS.
Content-Security-Policy default-src 'self'; … Restricts resource origins; frame-ancestors 'none' is the modern clickjacking control.
X-Frame-Options DENY Legacy clickjacking control for browsers predating frame-ancestors.
X-Content-Type-Options nosniff Stops MIME sniffing that turns uploads into executable script.
Referrer-Policy strict-origin-when-cross-origin Trims the Referer on cross-origin requests.
Permissions-Policy geolocation=(), camera=(), microphone=() Disables powerful browser features by default.

These are static strings on most apps. CSP with a per-request nonce needs the nonce computed inside the middleware and threaded into templates — the same problem the Django side solves; see setting CSP in Django middleware for the nonce model. For a static-asset/API service the fixed strings above are sufficient.

Server-Side Configuration

BaseHTTPMiddleware class

The ergonomic option. BaseHTTPMiddleware gives you a request/response pair and you mutate response.headers:

# security_headers.py
from starlette.middleware.base import BaseHTTPMiddleware

HEADERS = {
    "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
    "Content-Security-Policy": (
        "default-src 'self'; object-src 'none'; "
        "base-uri 'self'; frame-ancestors 'none'"
    ),
    "X-Frame-Options": "DENY",
    "X-Content-Type-Options": "nosniff",
    "Referrer-Policy": "strict-origin-when-cross-origin",
    "Permissions-Policy": "geolocation=(), camera=(), microphone=()",
}


class SecurityHeadersMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        response = await call_next(request)
        for name, value in HEADERS.items():
            response.headers.setdefault(name, value)
        return response
# main.py
from fastapi import FastAPI
from security_headers import SecurityHeadersMiddleware

app = FastAPI()
app.add_middleware(SecurityHeadersMiddleware)

setdefault writes the header only if a route handler did not already set it, so per-route overrides survive. Use plain assignment (response.headers[name] = value) when this middleware must be the single authoritative source.

Pure ASGI middleware (faster)

BaseHTTPMiddleware introduces an internal task and can interfere with streaming and background tasks. A pure-ASGI middleware avoids that overhead by editing the http.response.start message in place:

# asgi_security_headers.py
class SecurityHeadersASGI:
    def __init__(self, app):
        self.app = app
        self.headers = [
            (b"strict-transport-security",
             b"max-age=31536000; includeSubDomains; preload"),
            (b"content-security-policy",
             b"default-src 'self'; object-src 'none'; "
             b"base-uri 'self'; frame-ancestors 'none'"),
            (b"x-frame-options", b"DENY"),
            (b"x-content-type-options", b"nosniff"),
            (b"referrer-policy", b"strict-origin-when-cross-origin"),
            (b"permissions-policy",
             b"geolocation=(), camera=(), microphone=()"),
        ]

    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":
                existing = {k.lower() for k, _ in message["headers"]}
                for name, value in self.headers:
                    if name not in existing:
                        message["headers"].append((name, value))
            await send(message)

        await self.app(scope, receive, send_with_headers)
app.add_middleware(SecurityHeadersASGI)

Header names must be lowercase bytes; the existing-key check keeps it setdefault-equivalent so route overrides win. This form works correctly with streaming responses because it only touches the start message and never buffers the body.

app.add_middleware ordering

Starlette wraps middleware as an onion. The last middleware added is the outermost layer — it sees the request first and the response last. Order consequences:

app.add_middleware(SecurityHeadersASGI)        # added last → OUTERMOST
app.add_middleware(GZipMiddleware, minimum_size=500)
app.add_middleware(CORSMiddleware, allow_origins=["https://app.example.com"])

Per-route override

A single endpoint that must relax a header sets it on its own Response, and the middleware’s setdefault leaves it alone:

from fastapi import Response

@app.get("/embed")
def embeddable(response: Response):
    # This view is meant to be framed by a partner; relax the framing controls.
    response.headers["X-Frame-Options"] = "ALLOW-FROM https://partner.example.com"
    response.headers["Content-Security-Policy"] = "frame-ancestors https://partner.example.com"
    return {"ok": True}

Because the middleware uses setdefault, the route’s values are preserved for that one endpoint while every other response keeps the strict baseline.

Diagnostic & Verification Steps

Inspect the raw headers:

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

Assert the headers in CI with an httpx/pytest test against the ASGI app directly — no running server needed:

# test_security_headers.py
import httpx
import pytest
from main import app


@pytest.mark.anyio
async def test_security_headers_present():
    transport = httpx.ASGITransport(app=app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        r = await client.get("/")
    assert r.headers["x-frame-options"] == "DENY"
    assert r.headers["x-content-type-options"] == "nosniff"
    assert "default-src 'self'" in r.headers["content-security-policy"]
    assert r.headers["strict-transport-security"].startswith("max-age=31536000")
$ pytest test_security_headers.py -q
.                                                                        [100%]
1 passed in 0.18s

Critically, also assert headers on an error response (await client.get("/does-not-exist")404) — that is where misordered middleware most often drops the headers.

Edge Cases, Security Implications & Safe Rollback

Middleware execution order drops headers. If the security-header middleware is not the outermost layer, an outer middleware (or Starlette’s own exception handling) can produce a response the header middleware never wraps. Add it last in code so it is outermost, and prove it with the 404 test above.

BaseHTTPMiddleware and streaming responses. BaseHTTPMiddleware consumes the response inside an internal task, which can buffer or break StreamingResponse and server-sent events, and interferes with background tasks. For streaming endpoints use the pure-ASGI middleware, which edits only the http.response.start message and never touches the streamed body.

A reverse proxy may add the same headers → duplication. If Nginx, an ALB, or a CDN also injects HSTS or CSP, the client receives the header twice and browser behavior on duplicates is inconsistent. Pick one owner: either strip the proxy’s value (proxy_hide_header Strict-Transport-Security;) and emit from FastAPI, or stop emitting in FastAPI and own it at the edge. Confirm with curl -sI https://... | grep -c -i strict-transport-security returning 1. Also note HSTS over plain HTTP is ignored — only emit it when the connection (or X-Forwarded-Proto) is HTTPS.

Custom exception handlers can bypass middleware. A response generated by certain low-level error paths (or a handler that short-circuits before the middleware stack) may skip your headers. Keeping the header middleware outermost covers the normal exception flow; verify with a deliberately failing route that the headers still appear.

Safe rollback is non-destructive but removes protection, so do it deliberately:

  1. To loosen CSP without losing visibility, rename the header to Content-Security-Policy-Report-Only in the middleware.
  2. To remove fully, delete the app.add_middleware(SecurityHeadersASGI) line (or comment it out).
  3. Run the httpx test suite — the assertions will now fail, confirming the headers are gone where you expect.
  4. Restart workers gracefully: systemctl reload <service> or send the ASGI server (uvicorn/gunicorn) a HUP.
  5. Re-run curl -sI and confirm any headers you still rely on (for example HSTS emitted at the proxy) remain present — removing the app middleware must not silently drop edge-owned headers.

Frequently Asked Questions

Does FastAPI have a built-in security-headers middleware like Django’s SecurityMiddleware? No. FastAPI runs on Starlette, which ships no SecurityMiddleware equivalent. You add a BaseHTTPMiddleware class or a pure-ASGI middleware yourself, or use a helper library such as secure that wraps the same idea. See Django SecurityMiddleware vs custom headers for the Django contrast.

Should I use BaseHTTPMiddleware or a pure ASGI middleware? Use BaseHTTPMiddleware for its simpler request/response API on ordinary endpoints. Switch to pure-ASGI middleware when you stream responses or use background tasks, because BaseHTTPMiddleware runs the response in an internal task that can buffer streams and break background work. The ASGI form only edits the response-start message, so it is faster and stream-safe.

Why are my headers missing on error responses? The security-header middleware is not the outermost layer, so error responses produced higher in the stack bypass it. Add it last in code (app.add_middleware(...) last = outermost) and add a test that requests a non-existent route and asserts the headers on the 404.

My CDN already adds these headers — do I still need the middleware? Only one layer should own each header to avoid duplicates. If the CDN reliably sets them on every response, including errors, you can drop the app middleware; otherwise emit from FastAPI and strip the duplicate at the proxy. Confirm a single occurrence with curl -sI | grep -c -i <header>.

Conclusion

FastAPI has no built-in to lean on, so one middleware owns every security header. Roll it out incrementally: in staging, add the middleware with a short max-age (such as 300) on HSTS and CSP in Report-Only mode, run the httpx assertions plus the 404 test to prove the headers survive errors, then promote to production with max-age=31536000; includeSubDomains and an enforcing CSP — adding preload only once every subdomain is HTTPS-only.

Starlette middleware onion with the security-header layer outermost Nested rectangles show the Starlette middleware stack as an onion: the security-header middleware is the outermost layer wrapping CORS and GZip, which wrap the route handler, so it stamps headers onto every response including errors. SecurityHeaders middleware (outermost) CORS middleware GZip middleware Route handler returns Response

Response travels outward; outermost layer adds headers last, even on errors.

The security-header middleware is added last so it is the outermost onion layer, stamping headers onto every response — including errors raised inside the inner layers.