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.
Quick answer
A typical production baseline:
| Header | Recommended value | Set by |
|---|---|---|
Strict-Transport-Security | max-age=31536000; includeSubDomains (preload optional) | SECURE_HSTS_* settings |
Content-Security-Policy | a policy tailored to your assets; start in report-only mode | Django 6.0 CSP support, a package, or the proxy |
X-Frame-Options | DENY (or SAMEORIGIN) | X_FRAME_OPTIONS with XFrameOptionsMiddleware |
X-Content-Type-Options | nosniff | SECURE_CONTENT_TYPE_NOSNIFF |
Referrer-Policy | strict-origin-when-cross-origin or stricter | SECURE_REFERRER_POLICY |
Cross-Origin-Opener-Policy | same-origin | SECURE_CROSS_ORIGIN_OPENER_POLICY |
Permissions-Policy | disable features you do not use | proxy or middleware |
| Cookies | Secure, HttpOnly (session), SameSite=Lax | SESSION_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. includeSubDomainsapplies the policy to every subdomain, including internal tools and legacy hosts. Confirm they all serve HTTPS.- Preload (
SECURE_HSTS_PRELOAD = Trueand 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.
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 strictscript-src 'self'policy in current versions, but test it. frame-ancestorsin CSP supersedesX-Frame-Optionsin modern browsers; keep both for older clients.
3. Clickjacking protection
MIDDLEWARE = [
# ...
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
X_FRAME_OPTIONS = "DENY"
What to check:
XFrameOptionsMiddlewareis present (it is in the default project template).- Use
SAMEORIGINonly if your own pages frame each other. - Views that must be embedded elsewhere use
@xframe_options_exemptand a CSPframe-ancestorslist naming the allowed sites.
4. MIME type sniffing
SECURE_CONTENT_TYPE_NOSNIFF = True
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.
8. Cookie flags
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_SAMESITE = "Lax"
What to check:
Secureon session and CSRF cookies in production.HttpOnlyon the session cookie (defaultTrue). The CSRF cookie stays readable by JavaScript unless you useCSRF_USE_SESSIONSorCSRF_COOKIE_HTTPONLYwith a different token delivery.SameSite=Laxis the default and a good baseline.Noneis needed only for genuine cross-site embedding and requiresSecure.- Cookies set by your own code (
response.set_cookie) passsecure=True,httponly=True, andsamesiteexplicitly.
9. Remove information-leaking headers
Serverheaders revealing versions: setserver_tokens off;in Nginx.- Framework or platform headers such as
X-Powered-Byfrom 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,502error pages) - the SPA shell or other files served directly by the proxy
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 --deployreports 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.
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
includeSubDomainsbefore 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
- Understand each deployment warning in Django check --deploy Explained.
- Review related settings in the Django Production Settings Checklist.
- Cover broader controls with Django Deployment Security Basics You Should Not Skip.
- Check edge behavior with Deploy Django Behind Cloudflare.
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.