Web server

Content Security Policy on nginx

Setting a CSP on nginx takes one add_header line. Keeping it on every response is harder. nginx drops it from any location that adds a header of its own, without a warning, and a line break in the value stops the page loading over HTTP/2.

A complete report-only config

Two files: the policy as an include snippet, and a server block that uses it. The snippet is there because of the first gotcha below.

/etc/nginx/snippets/csp-report-only.conf
# Include this in the server block AND in every location that has an
# add_header of its own: a location with any add_header inherits none.
#
# Keep each value on one line. nginx sends a line break inside a quoted value
# as-is, and over HTTP/2 the browser rejects the whole response.
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 'self'; report-uri https://your-ingest-code.ingest.us.headerhawk.com/r; report-to csp-endpoint" always;
add_header Reporting-Endpoints 'csp-endpoint="https://your-ingest-code.ingest.us.headerhawk.com/r"' always;
/etc/nginx/conf.d/example.com.conf
server {
    listen 443 ssl;
    http2 on;
    server_name example.com;

    ssl_certificate     /etc/ssl/example.com/fullchain.pem;
    ssl_certificate_key /etc/ssl/example.com/privkey.pem;

    root /var/www/example.com;

    include snippets/csp-report-only.conf;

    location / {
        try_files $uri $uri/ =404;
    }

    # This location adds a header of its own, so it inherits nothing from the
    # server block. Without the include, every file under /assets/ is served
    # with no policy at all.
    location /assets/ {
        add_header Cache-Control "public, max-age=31536000, immutable" always;
        include snippets/csp-report-only.conf;
    }
}

How this was checked: run in Docker on nginx 1.30.4 (stable) and 1.31.5 (mainline), over HTTP/2 with TLS. nginx -t passes, both headers arrive on 200 and 404 responses and under /assets/, and the browser applies the policy and posts a report for an inline script.

Gotcha

add_header in a location block drops the server block's policy

A location inherits the add_header directives above it only if it has none of its own. Add a single Cache-Control to a location and every header the server block set, the policy and X-Content-Type-Options included, is gone from that location's responses.

server {
    add_header Content-Security-Policy-Report-Only "..." always;

    location /assets/ {
        # This one line removes the policy from everything under /assets/.
        add_header Cache-Control "public, max-age=31536000, immutable";
    }
}

Nothing fails. nginx -t passes, and curl -I against the root shows the policy. It is missing only behind that location, often where it matters most, such as an /app/ or an /admin/ with its own caching rules.

The fix that works on every version is the config above: include the snippet in the server block and in every location that sets a header. nginx 1.29.3 added add_header_inherit merge, which adds a level's headers to the ones above it. It is in the 1.30 stable branch too, so check nginx -v before relying on it.

server {
    add_header_inherit merge;   # nginx 1.29.3 and later
    add_header Content-Security-Policy-Report-Only "..." always;

    location /assets/ {
        # Added to the server block's headers, not instead of them.
        add_header Cache-Control "public, max-age=31536000, immutable" always;
    }
}

To find the affected paths, request something from each location, not the root: curl -sI https://example.com/assets/app.js.

Gotcha

Without always, error pages carry no policy

Without the always parameter, add_header only applies to a fixed list of status codes: 200, 201, 204, 206, 301, 302, 303, 304, 307 and 308. A 404, a 500, or the 403 from a deny rule is sent with none of the headers.

Error pages load your stylesheet and often your scripts, so a crafted URL that 404s lands the visitor on a page with no policy, and no reports. Every add_header in the config ends in always.

Gotcha

A line break in the value breaks HTTP/2

Plenty of published nginx examples split a long policy across lines. nginx accepts it, nginx -t passes, and it sends the line breaks inside the header value.

# Passes nginx -t. Fails every page load over HTTP/2.
add_header Content-Security-Policy-Report-Only "
    default-src 'self';
    script-src 'self'
" always;

A line break is not allowed in an HTTP/2 header. On nginx 1.30.4 with http2 on, Chrome refused the whole response with ERR_HTTP2_PROTOCOL_ERROR, so the page did not load. curl --http2 against the same URL returned 200 without the header, so the usual command-line check shows a working site with no policy.

Keep each value on one line, and in the snippet, so it lives in one place.

Now see what it's blocking

A report-only policy blocks nothing. When a browser loads a page and finds something the policy would have refused, it posts a report to the endpoint in report-uri and report-to. Until something listens there, those reports go nowhere.

HeaderHawk is one place to send them. It accepts both report formats on the same URL, groups them by directive and blocked host so a thousand reports about one script read as one issue, and keeps extension and bot reports visible but out of alerts. Replace your-ingest-code in the config above with the code from your site's setup panel, or paste the URL the panel shows.

The nginx integration guide covers that step: where the endpoint goes on nginx, and how to confirm the headers are being served.

A policy is the first of several controls browsers enforce on the code your pages load, including Integrity-Policy, Permissions-Policy, COOP and COEP, and they all report to the same endpoint. What client-side security is covers the rest of that layer.

Start reading the reports

Free for 1 site. Leave the policy in report-only until the reports go quiet, then enforce it.