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.

How Django's response phase decides which middleware winsThe request descends the MIDDLEWARE list into the view and the response ascends back out, so the class listed first touches the response last and overrides shared headers. Request descends the list, response ascends back out Request Response ExtraSecurityHeaders (custom) SecurityMiddleware SessionMiddleware View (get_response) top to bottom bottom to top Listed first = outermost = touches the response last = overrides shared headers.
Because Django unwinds the response phase from the bottom of the list upward, the earliest-listed middleware writes last and wins any header both classes set.

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:

  1. Comment out the custom middleware string in MIDDLEWARE.
  2. Run python manage.py check to catch config drift.
  3. Reload workers gracefully: systemctl reload gunicorn or kill -HUP <pid>.
  4. Re-run curl -I and 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.

Incremental HSTS rollout timelineFour ordered stages move HSTS from a short staging max-age through verification to a one-year production value and finally the preload flag. Roll HSTS out one stage at a time 1. Staging 2. Verify 3. Production 4. Preload max-age=300 curl -I + check --deploy max-age=31536000 add preload Raise max-age only after a stage verifies; add preload once every subdomain is HTTPS-only.
A short staging max-age is reversible; a year-long value with preload is effectively permanent, so each step gates the next.
Which headers Django SecurityMiddleware sets vs needs custom code A matrix listing security headers and whether Django's SecurityMiddleware sets them automatically or whether a custom middleware or package is required. SecurityMiddleware sets Needs custom / package Strict-Transport-Security X-Content-Type-Options Referrer-Policy X-Frame-Options (XFrameOptionsMiddleware) Content-Security-Policy Permissions-Policy Cross-Origin-Opener-Policy Cross-Origin-Embedder-Policy Driven by SECURE_* settings django-csp or custom class A clean manage.py check --deploy does not prove the right column exists.
Django's built-ins cover the left column; the right column has no SECURE_* setting and must come from django-csp or a custom middleware class.