Django SecurityMiddleware vs Custom Headers: When to Use Each
This guide is part of the FastAPI & Django Security Middleware reference. Django ships exactly one header middleware, django.middleware.security.SecurityMiddleware, which sets a fixed subset of response headers from SECURE_* settings. Everything outside that subset — most importantly Content-Security-Policy and Permissions-Policy — requires a custom middleware class or a third-party package. The decision is not “built-in or custom”; it is “built-in plus custom for the gaps.” This page gives the exact settings, the gap list, and the ordering rules that decide which value wins.
Configuration Syntax & Exact Values
SecurityMiddleware reads these settings and emits the corresponding headers. The right column is the production baseline:
| Setting | Header emitted | Baseline value |
|---|---|---|
SECURE_HSTS_SECONDS |
Strict-Transport-Security (HTTPS only) |
31536000 |
SECURE_HSTS_INCLUDE_SUBDOMAINS |
adds includeSubDomains |
True |
SECURE_HSTS_PRELOAD |
adds preload |
True |
SECURE_CONTENT_TYPE_NOSNIFF |
X-Content-Type-Options: nosniff |
True |
SECURE_REFERRER_POLICY |
Referrer-Policy |
strict-origin-when-cross-origin |
SECURE_SSL_REDIRECT |
301 redirect http→https (not a header) | True |
X-Frame-Options is not part of SecurityMiddleware — it is emitted by the separate django.middleware.clickjacking.XFrameOptionsMiddleware from the X_FRAME_OPTIONS setting (DENY or SAMEORIGIN).
A custom middleware class fills the gaps. The minimal, override-safe form:
# myapp/middleware.py
class ExtraSecurityHeadersMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
# Headers SecurityMiddleware never sets:
response.setdefault("Permissions-Policy", "geolocation=(), camera=(), microphone=()")
response.setdefault("Cross-Origin-Opener-Policy", "same-origin")
return response
setdefault only writes the header if a view (or django-csp) has not already set it, so per-route overrides survive. Use plain assignment (response["..."] = ...) only when this middleware must be the authoritative source.
Server-Side Configuration
Django settings
# settings.py
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", # FIRST — outermost
"myapp.middleware.ExtraSecurityHeadersMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"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"
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") # behind TLS-terminating proxy
Django custom middleware
Place the custom class above SecurityMiddleware only when it must override a value SecurityMiddleware also sets. Django runs MIDDLEWARE top-to-bottom on the request and bottom-to-top on the response, so a class listed earlier runs later on the response and its value wins:
MIDDLEWARE = [
"myapp.middleware.ExtraSecurityHeadersMiddleware", # runs last on response → overrides
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
]
The mental model that makes the ordering predictable is an onion: the request travels down the list into the view, and the response travels back up to the client. A class listed first is the outermost layer — it is the last to touch the response — so its header value is the one that survives.
For headers SecurityMiddleware does not touch (CSP, Permissions-Policy), position relative to SecurityMiddleware is irrelevant — there is no conflict to resolve. Keep the custom class near the top for predictability.
Async compatibility. Under an ASGI server the plain synchronous class above still works, but Django wraps it in a sync-to-async adapter on every request, adding a thread hop. If the rest of your stack is async, mark the class so Django calls it natively: set async_capable = True and sync_capable = False as class attributes and make __call__ an async def that awaits self.get_response(request). Mixing capabilities is safe — Django adapts each middleware to the mode the server is running — but matching the class to your deployment removes the per-request adapter and keeps django.request tracebacks shallow.
FastAPI contrast. FastAPI/Starlette ships no SecurityMiddleware equivalent, so there are no built-ins to coordinate with — you set every header in one @app.middleware("http") function or ASGI class, and the last-registered middleware runs outermost. The full FastAPI pattern is in FastAPI & Django Security Middleware.
Diagnostic & Verification Steps
Inspect the raw headers. Expected output with the configuration above:
$ curl -sI https://your-domain.com/ | grep -iE '(strict-transport|x-frame|x-content-type|referrer-policy|permissions-policy)'
strict-transport-security: max-age=31536000; includeSubDomains; preload
x-frame-options: DENY
x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin
permissions-policy: geolocation=(), camera=(), microphone=()
Run the deploy check to audit the SECURE_* settings:
$ python manage.py check --deploy
System check identified no issues (0 silenced).
The deploy check warns about SECURE_HSTS_SECONDS = 0, SECURE_SSL_REDIRECT = False, and DEBUG = True. It does not check for a missing Content-Security-Policy or Permissions-Policy, because no setting controls them — a clean check is not proof those headers exist.
Edge Cases, Security Implications & Safe Rollback
Middleware order overwrites your value. If the custom class is listed after SecurityMiddleware, it runs earlier on the response and SecurityMiddleware overwrites any header they share (e.g. Referrer-Policy). Move the custom class above SecurityMiddleware to win the conflict, or simply use distinct headers so no conflict exists.
What SecurityMiddleware does NOT set. It sets only HSTS, X-Content-Type-Options, and Referrer-Policy (plus the SSL redirect). It does not set Content-Security-Policy, Permissions-Policy, Cross-Origin-Opener-Policy/Cross-Origin-Embedder-Policy, or X-Frame-Options (that last one is XFrameOptionsMiddleware). Shipping only the built-ins leaves the application with no XSS-mitigating CSP. Add django-csp or a custom class for these; the CSP-specific path is in setting CSP in Django middleware.
Proxy override and duplicates. A reverse proxy or CDN that also injects HSTS produces duplicate headers. Decide one owner: either strip the app’s value at the proxy (proxy_hide_header/Header unset) and assert at the edge, or disable the proxy’s injection and keep Django authoritative. Confirm uniqueness with curl -sI https://... | grep -c -i strict-transport-security returning 1. Also ensure the proxy forwards X-Forwarded-Proto, or Django sees plain HTTP and emits no HSTS at all.
Safe rollback. Removing a custom header class is non-destructive but can silently drop coverage:
- Comment out the custom middleware string in
MIDDLEWARE. - Run
python manage.py checkto catch config drift. - Reload workers gracefully:
systemctl reload gunicornorkill -HUP <pid>. - Re-run
curl -Iand confirm the built-in headers (X-Frame-Options, HSTS) are still present — rolling back the custom class must not leave clickjacking or feature-policy gaps.
Frequently Asked Questions
Does SecurityMiddleware set X-Frame-Options?
No. X-Frame-Options comes from django.middleware.clickjacking.XFrameOptionsMiddleware driven by the X_FRAME_OPTIONS setting. SecurityMiddleware sets HSTS, X-Content-Type-Options, Referrer-Policy, and the SSL redirect only.
Do I need a custom middleware if I use the built-ins?
Yes, if you want a Content-Security-Policy, Permissions-Policy, or cross-origin isolation headers — SecurityMiddleware sets none of those. Use django-csp for CSP and a small custom class for the rest.
Why does manage.py check --deploy pass when I have no CSP?
Because there is no Django setting for CSP, the deploy check has nothing to inspect. A clean deploy check audits only the SECURE_* settings; verify CSP and Permissions-Policy directly with curl -sI.
How do I make a custom header override a built-in one?
List the custom middleware above SecurityMiddleware in MIDDLEWARE. Django runs the response phase bottom-to-top, so the earlier-listed middleware touches the response last and its value wins.
Conclusion
Lean on the built-ins for the headers they cover — HSTS, X-Content-Type-Options, Referrer-Policy, and (via XFrameOptionsMiddleware) X-Frame-Options — and add one small custom class plus django-csp for the gaps. Roll it out incrementally: enable the SECURE_* settings in staging with a short SECURE_HSTS_SECONDS (such as 300), confirm with curl -I and manage.py check --deploy, then raise max-age to a year and add preload only once every subdomain is HTTPS-only.
Related
- FastAPI & Django Security Middleware
- Setting CSP in Django middleware
- Adding security headers in FastAPI middleware