Nginx guide · Content-Security-Policy

How to set Content-Security-Policy on Nginx

Add a Content-Security-Policy header in Nginx, test it in report-only mode, keep it on every location and error page, and add nonces or hashes. Tested configs.

  • By
  • Updated
  • 5 min read
  • Tested on nginx 1.28, 1.30 and 1.31
$ curl -sI https://example.com/missing
status
HTTP/2 404
server
nginx
content-security-policy
default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'

Present on error pages too

In short5 points
  • Set the policy with add_header Content-Security-Policy "…" always; in the server block. Without always, 404 and 500 pages are sent without it.
  • A location with its own add_header silently drops every header from the server block. Share the line through an include file, or use add_header_inherit merge; on nginx 1.29.3 or later.
  • Start with Content-Security-Policy-Report-Only and a Reporting-Endpoints header, then rename the header once violations stop.
  • Nginx can inject a per-request nonce with sub_filter and $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_header so 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:

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;
Shell
sudo nginx -t && sudo systemctl reload nginx

Two 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:

Shell
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:

Nginx
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:

Nginx
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.

Nginx
# /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;
Nginx
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:

Nginx
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.

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; 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:

Shell
printf '%s' "console.log('ok')" | openssl sha256 -binary | openssl base64
# z45zR03J8fjhB+XZI75tfFNuEulXqY6Qn0ZMYwFwVws=
Nginx
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:

Nginx
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;
}
HTML
<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:

Nginx
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:

Nginx
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:

Shell
curl -sI https://example.com/ | grep -i content-security-policy
curl -sI https://example.com/does-not-exist | grep -i content-security-policy

Count the headers to catch duplicates. The result should be 1:

Shell
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

    Planned

    Not written yet. The overview’s policy and rollout steps apply to every server.

  • Caddy

    Planned

    Not written yet. The overview’s policy and rollout steps apply to every server.

  • Traefik

    Planned

    Not 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

About the author

Portrait of Joshua Martinelle

Security engineer at Tenable

Security engineer at Tenable and web security researcher. He has disclosed more than 60 CVEs since 2020 and builds WebSec0.

Verify the change

Deployed it? Check it.

WebSec0 grades your TLS setup and security headers, including Content-Security-Policy, in a few seconds. Free, no account.

Scan your site