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",
]

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.

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.

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.