- Set the policy with
add_header Content-Security-Policy "…" always;in theserverblock. Withoutalways, 404 and 500 pages are sent without it. - A
locationwith its ownadd_headersilently drops every header from theserverblock. Share the line through anincludefile, or useadd_header_inherit merge;on nginx 1.29.3 or later. - Start with
Content-Security-Policy-Report-Onlyand aReporting-Endpointsheader, then rename the header once violations stop. - Nginx can inject a per-request nonce with
sub_filterand$request_id, but nonces generated by your application, or hashes on static sites, are safer. - Behind a reverse proxy, hide the upstream's CSP with
proxy_hide_headerso the browser does not receive two policies.
The short answer
Add this line to the server block of your site, check the configuration and
reload Nginx:
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests" always;sudo nginx -t && sudo systemctl reload nginxTwo details make or break this line. The always parameter sends the header
on error pages too, and any location block with its own add_header stops
inheriting it. The rest of this guide shows how to roll the policy out safely
and avoid both traps. Every snippet below was run on nginx 1.28, 1.30 and 1.31
and checked with curl.
If you have not chosen a policy yet, read what each CSP directive does first. The policy above suits a site that loads everything from its own domain and has no inline scripts.
Before you start
Find the file that defines your site. nginx -T prints the full, merged
configuration, so you can see every server block and every existing
add_header:
sudo nginx -T | grep -nE "server_name|add_header|include"Note any location that already has add_header lines (cache headers are the
usual suspect). Those locations will need attention in step 2.
Inside the header value, wrap the whole policy in double quotes and keep the
single quotes around keywords like 'self' and 'none'. A stray double quote
inside the value ends the string early, and nginx -t fails with an
unexpected "s" style error.
Step 1: ship the policy in report-only mode
Report-only mode applies the policy without blocking anything. Pair it with a
Reporting-Endpoints header so violations from real visitors reach you, not
only your own console:
server {
# ...
add_header Reporting-Endpoints 'csp-endpoint="https://example.com/csp-reports"' always;
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; report-to csp-endpoint; report-uri https://example.com/csp-reports" always;
}report-to sends reports through the Reporting API and works in all major
browsers since 2026. report-uri is the older mechanism: keep it as a fallback,
since browsers that understand report-to ignore it. The endpoint must use
HTTPS. A hosted CSP reporting service works, or any route of your own that
accepts POST requests.
Browse your main pages with the developer console open, fix what is reported, and let real traffic run against the policy for a few days.
Step 2: keep the header on every location
This is the trap specific to Nginx. add_header directives are
inherited from the parent block only if the current block has none. In this
configuration, files under /assets/ are served without the policy:
server {
add_header Content-Security-Policy "default-src 'self'; ..." always;
location /assets/ {
# This one line removes the server-level CSP for /assets/.
add_header Cache-Control "public, max-age=31536000, immutable";
}
}There are two fixes. The first works on every version: put your security headers in a snippet and include it wherever a block sets its own headers.
# /etc/nginx/snippets/security-headers.conf
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests" always;server {
include /etc/nginx/snippets/security-headers.conf;
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
include /etc/nginx/snippets/security-headers.conf;
}
}The second needs nginx 1.29.3 or later, which includes the 1.30 stable
branch. add_header_inherit merge; makes child blocks append their headers to
the parent’s instead of replacing them. Set it once at the http or server
level and it applies to every nested block:
server {
add_header_inherit merge;
add_header Content-Security-Policy "default-src 'self'; ..." always;
location /assets/ {
# Sent together with the server-level CSP.
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
}On nginx 1.28 and older, nginx -t rejects this with
unknown directive "add_header_inherit". Check your version with nginx -v.
Step 3: enforce the policy
When the reports have gone quiet, rename the header from
Content-Security-Policy-Report-Only to Content-Security-Policy. Keep the
reporting directives: they now report blocked resources.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests; report-to csp-endpoint; report-uri https://example.com/csp-reports" always;To tighten the policy later, add a stricter candidate as a second, report-only header next to the enforced one. The browser applies the first and only reports on the second.
Hashes for inline scripts on static sites
A static site cannot use nonces, because the same HTML is served to everyone.
List the hash of each inline script instead. The hash covers the exact text
between <script> and </script>, whitespace included:
printf '%s' "console.log('ok')" | openssl sha256 -binary | openssl base64
# z45zR03J8fjhB+XZI75tfFNuEulXqY6Qn0ZMYwFwVws=add_header Content-Security-Policy "script-src 'self' 'sha256-z45zR03J8fjhB+XZI75tfFNuEulXqY6Qn0ZMYwFwVws='; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;Any change to the script, even a space, changes the hash. Generate the list at build time, or let the browser tell you: the console error for a blocked inline script includes the hash it expected.
Nonces with Nginx
Nginx has no nonce generator, but $request_id is a random 128-bit value,
unique to each request. You can use it in the header and write it into your
HTML with sub_filter, replacing a placeholder on each <script> tag:
location / {
proxy_pass http://app;
# sub_filter cannot rewrite compressed responses.
proxy_set_header Accept-Encoding "";
sub_filter_once off;
sub_filter "__CSP_NONCE__" $request_id;
add_header Content-Security-Policy "script-src 'nonce-$request_id' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" always;
}<script nonce="__CSP_NONCE__" src="/app.js"></script>It works, but it has a real weakness: Nginx replaces the placeholder everywhere in the page, including inside markup an attacker managed to inject. Anyone who knows your placeholder string gets a valid nonce. Treat this as a stopgap for applications you cannot change. When you can, generate the nonce in your application (most frameworks have a CSP middleware) and let Nginx pass the header through. Also make sure no CDN caches these HTML pages, or every visitor shares one nonce.
Behind a reverse proxy
When Nginx proxies an application that already sends a CSP, the response carries both headers. The browser enforces each policy, so the stricter combination wins and things break in confusing ways. Decide who owns the policy. If it is Nginx, hide the upstream’s header:
location / {
proxy_pass http://app;
proxy_hide_header Content-Security-Policy;
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
}Use fastcgi_hide_header for PHP-FPM and uwsgi_hide_header for uWSGI. If the
application owns the policy, remove the add_header line from Nginx instead.
To send the policy only on HTML responses, and skip it on images, scripts and stylesheets, build the value from the response type. Nginx does not add a header whose value is empty:
map $sent_http_content_type $csp {
~^text/html "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'";
default "";
}
server {
add_header Content-Security-Policy $csp always;
}The map block goes in the http context, outside any server.
Verify it
Check the header on a normal page and on an error page:
curl -sI https://example.com/ | grep -i content-security-policy
curl -sI https://example.com/does-not-exist | grep -i content-security-policyCount the headers to catch duplicates. The result should be 1:
curl -sI https://example.com/ | grep -ci '^content-security-policy:'Then test one URL from each location that sets its own headers, and scan the
site with WebSec0. The headers report shows the policy it received and whether
it passes.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Header missing on 404 or 500 pages | add_header without always |
Add always to the line. |
| Header missing on some URLs | That location has its own add_header |
Include the snippet there, or use add_header_inherit merge; (1.29.3+). |
Two Content-Security-Policy headers |
The upstream app sends one too | proxy_hide_header Content-Security-Policy; or remove the Nginx line. |
unknown directive "add_header_inherit" |
nginx older than 1.29.3 | Upgrade, or use the include approach. |
nginx -t reports unexpected "…" |
A double quote inside the header value | Keep the value in double quotes and keywords in single quotes. |
| Inline scripts blocked after enforcing | No nonce or hash for them | Move them to files, or add hashes or nonces. |
| Changes do not show up | Reload skipped, or a CDN cached old headers | nginx -t && systemctl reload nginx, then purge the CDN. |
Other web servers
Content-Security-Policy on other servers
Apache
PlannedNot written yet. The overview’s policy and rollout steps apply to every server.
Caddy
PlannedNot written yet. The overview’s policy and rollout steps apply to every server.
Traefik
PlannedNot written yet. The overview’s policy and rollout steps apply to every server.
Questions
Frequently asked questions
Where should add_header go: http, server or location?
Put it in the server block of each site, or in a snippet included there. A header in http reaches every site, but it is dropped in any server or location that defines its own add_header, which is easy to miss.
Why is my Content-Security-Policy missing on 404 pages?
Without the always parameter, Nginx only adds headers to 200, 201, 204, 206, 301, 302, 303, 304, 307 and 308 responses. Add always at the end of the add_header line.
Why does Nginx drop my CSP header in some locations?
add_header directives are inherited from the parent level only when the current level has none. One add_header Cache-Control … in a location removes the parent's CSP there. Repeat the header with an include file, or set add_header_inherit merge; on nginx 1.29.3 or later, including the 1.30 stable branch.
Can Nginx generate a CSP nonce?
Not natively, but $request_id gives 128 random bits per request. Use it in the header and replace a placeholder in the HTML with sub_filter. Generating the nonce in your application is safer, because injected markup that contains the placeholder would receive a valid nonce too.
Should the CSP header be sent on CSS, JavaScript and image files?
It is harmless there but only matters on documents. To send it only on HTML, set it from a map on $sent_http_content_type: Nginx skips add_header when the value is empty.
Why does my response contain two Content-Security-Policy headers?
Your application and Nginx both send one. The browser enforces both, so the stricter combination applies. Add proxy_hide_header Content-Security-Policy; (or fastcgi_hide_header for PHP-FPM) if Nginx should own the policy.
References
Sources
- Module ngx_http_sub_modulenginx.org
- Embedded variables: $request_idnginx.org
- Content-Security-Policy header referenceMDN Web Docs
- Reporting-Endpoints headerMDN Web Docs
