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

Author: Joshua Martinelle, Security engineer at Tenable (https://www.websec0.com/guides/authors/joshua-martinelle/)
Canonical: https://www.websec0.com/guides/content-security-policy/nginx/
Updated: 2026-10-07
Tested on: nginx 1.28, 1.30 and 1.31

## In short

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

```sh
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](/guides/content-security-policy/) 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`:

```sh
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:

```sh
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:

```sh
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`:

```sh
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. |

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

## Sources

- [Module ngx_http_headers_module: add_header and add_header_inherit](https://nginx.org/en/docs/http/ngx_http_headers_module.html), nginx.org
- [Module ngx_http_sub_module](https://nginx.org/en/docs/http/ngx_http_sub_module.html), nginx.org
- [Embedded variables: $request_id](https://nginx.org/en/docs/http/ngx_http_core_module.html#var_request_id), nginx.org
- [Content-Security-Policy header reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy), MDN Web Docs
- [Reporting-Endpoints header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Reporting-Endpoints), MDN Web Docs
- [Content Security Policy Level 3](https://www.w3.org/TR/CSP3/), W3C
