Django Security Headers Checklist

Check every HTTP security header a Django site should send: HSTS, CSP, frame options, nosniff, referrer and permissions policies, COOP, and cookie flags.

Problem statement

HTTP security headers tell browsers how to treat your site: only connect over HTTPS, refuse to be framed by other sites, not guess content types, restrict where scripts load from, and limit what information leaks to other sites. Django sets several of them through SecurityMiddleware and related settings, but defaults are conservative, some headers need decisions only you can make, and headers set in Nginx or a CDN can conflict with Django's.

Missing headers rarely break anything visibly, which is why they are easy to forget. This checklist covers each header a production Django site should consider, the Django setting or proxy configuration that controls it, safe starting values, and how to verify what browsers actually receive.

Headers on every responseHSTSforce HTTPSCSPlimit script sourcesFrame and typeclickjack, sniffingReferrerlimit leakageSet them where they are easiest to audit, then verify on the live response.

Quick answer

A typical production baseline:

HeaderRecommended valueSet by
Strict-Transport-Securitymax-age=31536000; includeSubDomains (preload optional)SECURE_HSTS_* settings
Content-Security-Policya policy tailored to your assets; start in report-only modeDjango 6.0 CSP support, a package, or the proxy
X-Frame-OptionsDENY (or SAMEORIGIN)X_FRAME_OPTIONS with XFrameOptionsMiddleware
X-Content-Type-OptionsnosniffSECURE_CONTENT_TYPE_NOSNIFF
Referrer-Policystrict-origin-when-cross-origin or stricterSECURE_REFERRER_POLICY
Cross-Origin-Opener-Policysame-originSECURE_CROSS_ORIGIN_OPENER_POLICY
Permissions-Policydisable features you do not useproxy or middleware
CookiesSecure, HttpOnly (session), SameSite=LaxSESSION_COOKIE_*, CSRF_COOKIE_*

Verify with curl -I and python manage.py check --deploy.

Checklist

1. HTTPS enforcement and HSTS

SECURE_SSL_REDIRECT = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")  # only behind a trusted proxy
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = False

What to check:

  • HTTPS works everywhere first. HSTS makes browsers refuse plain HTTP for the duration of max-age; enabling it before every page and subdomain supports HTTPS locks users out.
  • Ramp up SECURE_HSTS_SECONDS: start with 300 (five minutes), then a day, then a year once stable.
  • includeSubDomains applies the policy to every subdomain, including internal tools and legacy hosts. Confirm they all serve HTTPS.
  • Preload (SECURE_HSTS_PRELOAD = True and submitting the domain to the browser preload list) is hard to reverse. Only do it deliberately.
  • Set HSTS in one place. If Nginx also adds Strict-Transport-Security, responses can contain duplicate or conflicting values.

2. Content Security Policy (CSP)

CSP is the most powerful header against cross-site scripting (XSS), and the most likely to break pages if introduced carelessly. It lists allowed sources for scripts, styles, images, frames, and connections.

a strict starting policydefault-src 'self';script-src'self';img-src 'self' data:;frame-ancestors'none';base-uri 'self';form-action 'self';object-src'none'12341No inline scripts; move them to files or use nonces.2No framing at all: replaces X-Frame-Options.3Forms cannot post to another site.4No plugins, ever.Start in report-only mode; the admin and third-party widgets need testingdjango-deployment.com
Django 6.0 ships CSP support; older versions use django-csp.

Options for Django:

  • Django 6.0 and later include built-in Content Security Policy support through middleware and settings, including nonces for inline scripts.
  • Earlier versions use a package such as django-csp, or set the header in Nginx.

A starting policy for a site that serves its own assets:

default-src 'self';
script-src 'self';
style-src 'self';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none'

What to check:

  • Start in report-only mode (Content-Security-Policy-Report-Only) and collect violation reports for a while before enforcing.
  • Inventory third parties: analytics, CDNs, payment widgets, fonts, and embedded videos each need explicit sources.
  • Avoid 'unsafe-inline' for scripts. Use nonces or move inline scripts into static files. The Django admin works with a strict script-src 'self' policy in current versions, but test it.
  • frame-ancestors in CSP supersedes X-Frame-Options in modern browsers; keep both for older clients.

3. Clickjacking protection

MIDDLEWARE = [
    # ...
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]
X_FRAME_OPTIONS = "DENY"

What to check:

  • XFrameOptionsMiddleware is present (it is in the default project template).
  • Use SAMEORIGIN only if your own pages frame each other.
  • Views that must be embedded elsewhere use @xframe_options_exempt and a CSP frame-ancestors list naming the allowed sites.

4. MIME type sniffing

SECURE_CONTENT_TYPE_NOSNIFF = True
the smaller headers, with valuesValueX-Content-Type-Options✓nosniffReferrer-Policy✓strict-origin-when-cross-originCross-Origin-Opener-Policy✓same-originPermissions-Policycamera=(), microphone=(), geolocation=()The first three come from Django settings; the last needs middleware or the proxydjango-deployment.com
SecurityMiddleware sets the first three when the matching settings are on.

Default True in current Django. It prevents browsers from interpreting uploaded or static files as a different content type (for example executing a text file as script).

What to check: static files and user uploads served by Nginx or a CDN also carry X-Content-Type-Options: nosniff, because SecurityMiddleware does not see those responses. Add it in the proxy for /static/ and /media/ locations.

5. Referrer Policy

SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"

Default in current Django is same-origin, which sends no referrer to other sites. strict-origin-when-cross-origin sends only the origin (not the path) to other HTTPS sites, which some analytics or partner integrations expect. Choose the strictest value your integrations allow; URLs can contain tokens or personal identifiers.

6. Cross-Origin-Opener-Policy

SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin"

Default same-origin in current Django. It isolates your browsing context from cross-origin windows, which mitigates some cross-window attacks. If your site uses OAuth or payment popups that need to communicate back through window.opener, same-origin-allow-popups may be required.

7. Permissions Policy

Django does not set Permissions-Policy by default. Disable browser features the site does not use:

add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;

What to check: features used by your pages (for example geolocation on a store locator) are allowed for self rather than disabled.

SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"

CSRF_COOKIE_SECURE = True
CSRF_COOKIE_SAMESITE = "Lax"

What to check:

  • Secure on session and CSRF cookies in production.
  • HttpOnly on the session cookie (default True). The CSRF cookie stays readable by JavaScript unless you use CSRF_USE_SESSIONS or CSRF_COOKIE_HTTPONLY with a different token delivery.
  • SameSite=Lax is the default and a good baseline. None is needed only for genuine cross-site embedding and requires Secure.
  • Cookies set by your own code (response.set_cookie) pass secure=True, httponly=True, and samesite explicitly.

9. Remove information-leaking headers

  • Server headers revealing versions: set server_tokens off; in Nginx.
  • Framework or platform headers such as X-Powered-By from other components.
  • Debug headers added by development middleware.

These are low-severity on their own but make reconnaissance easier.

10. Headers on every response path

Security headers set by Django only appear on responses that pass through Django. Check each path:

  • Django views (including error pages: 404, 500)
  • static files and media served by Nginx, WhiteNoise, or a CDN
  • responses generated by Nginx itself (redirects, 413, 502 error pages)
  • the SPA shell or other files served directly by the proxy
not every response comes from DjangoDjango viewsSecurityMiddleware adds headersStatic and media from Nginxadd_header in those locationsNginx error pages (502, 504)add_header … alwaysCDN cached responsesmay strip or keep headersNginx add_header in a location replaces, not adds to, server-level headersdjango-deployment.com
Check headers on a static file and on a 404, not only on the home page.

In Nginx, add_header only applies to successful and redirect responses unless you add always, and add_header directives in a location replace those inherited from the server block.

Verify

curl -sI https://www.example.com/ | grep -i -E "strict-transport|content-security|x-frame|x-content-type|referrer-policy|cross-origin-opener|permissions-policy|set-cookie"
curl -sI https://www.example.com/static/css/app.css | grep -i -E "x-content-type|strict-transport"
python manage.py check --deploy

Browser developer tools (Network tab) show response headers and CSP violations in the console. Online header scanners give a quick external report, but treat their grades as hints rather than requirements.

Verification check:

  • all headers above are present on HTML responses with the intended values
  • static and media responses carry nosniff
  • no duplicate headers from both Django and the proxy
  • CSP report-only mode shows no unexpected violations before enforcement
  • check --deploy reports no security warnings you have not deliberately accepted

Explanation

Security headers shift enforcement to the browser. HSTS prevents protocol downgrade, CSP limits the impact of injected scripts, frame options stop clickjacking, nosniff prevents content type confusion, and referrer and opener policies limit cross-site information leakage. Django covers most of these with settings, but headers must be consistent across every component that generates responses, and some (CSP, HSTS preload) require deliberate, staged rollouts.

Edge cases / notes

CDNs adding or stripping headers

Some CDN features add security headers or rewrite HTML (which can break CSP nonces). Check headers at the edge, not only at the origin.

Multiple applications on subdomains

includeSubDomains and cookie domain settings affect every subdomain. Coordinate with other teams before enabling them on a shared parent domain.

Admin and third-party apps

Test the Django admin and any third-party app pages under your CSP. Packages with inline scripts may require nonces or policy exceptions.

Rolling out a Content Security Policy in stages

CSP deserves its own plan, because a strict policy applied at once usually breaks something.

report first, enforce laterReport-Only headernothing blockedCollect violationsa week of trafficFix or allow sourcesinline scripts, CDNsEnforceContent-Security-PolicyKeep reportingcatch regressionsEnforcing on day one breaks the admin and every third-party widgetdjango-deployment.com
Report endpoints receive noise from browser extensions; filter it out.

Stage 1: observe. Deploy a report-only policy that mirrors what you believe your site needs:

Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; report-uri /csp-report/

Collect reports for one to two weeks so weekly and monthly flows (invoices, campaigns, seasonal pages) are covered.

Stage 2: analyze. Group reports by violated directive and blocked URI. Expect three categories:

  • legitimate third parties you forgot (payment scripts, analytics, fonts, embedded video)
  • browser extensions and injected scripts, which generate noise you can ignore
  • your own inline scripts and styles, which need nonces or extraction into files

Store reports in a way you can query; a simple view that logs them with rate limiting is enough for small sites.

Stage 3: tighten and enforce. Add the legitimate sources, remove 'unsafe-inline' where nonces can replace it, then switch the header from report-only to enforcing. Keep the report endpoint active so regressions remain visible.

Stage 4: maintain. New third-party integrations need policy updates; make that part of the review checklist for features that add external scripts.

A note on nonces

Nonces let specific inline scripts run under a strict policy. Django 6.0's CSP support provides a nonce through a context processor, and third-party packages offer similar mechanisms. Each response must use a fresh nonce, which means pages using nonces cannot be cached by shared caches unless the nonce is regenerated per response.

Common mistakes

  • Enabling a long HSTS max-age with includeSubDomains before all subdomains support HTTPS.
  • Enforcing a CSP without a report-only period.
  • Setting the same header in both Nginx and Django with different values.
  • Forgetting headers on static files and proxy-generated error pages.

Next steps

FAQ

Which security headers does Django set by default?

With SecurityMiddleware and XFrameOptionsMiddleware in the default template, Django sends X-Content-Type-Options: nosniff, Referrer-Policy: same-origin, Cross-Origin-Opener-Policy: same-origin, and X-Frame-Options: DENY. HSTS and HTTPS redirects need explicit settings.

How do I add a Content Security Policy to Django?

Django 6.0 and later provide built-in CSP support; earlier versions can use a package such as django-csp or set the header in the web server. Start with a report-only policy and tighten it based on reports.

Is it safe to enable HSTS preload for my Django site?

Only when every subdomain permanently supports HTTPS. Removal from browser preload lists takes a long time, so treat preload as a long-term commitment.

Why are security headers missing on my static files?

Static and media files served by Nginx, WhiteNoise, or a CDN do not pass through Django's SecurityMiddleware in the same way. Configure the relevant headers in the component that serves them.

Learning path · step 4 of 15

Hardening a Django deployment

Keep going