Security headers tell browsers to enforce HTTPS, block untrusted frames, and ignore suspicious responses before malicious code reaches your users. Configuring these headers in NGINX gives every site on your server immediate protection against cross-site scripting, clickjacking, downgrade attacks, and data leaks. These headers work at the HTTP protocol level, requiring no application changes while defending against entire classes of web vulnerabilities.
This guide shows how to create a reusable header snippet and apply it across all server blocks. You’ll configure HSTS to enforce HTTPS, CSP to restrict resource origins, X-Frame-Options to block clickjacking, and Permissions-Policy to restrict browser APIs. The guide includes curl-based verification commands to confirm headers appear in every response. If you are still building the stack, start with installing NGINX on Ubuntu, then return here to harden the configuration.
Create a Reusable Security Headers Snippet
Before configuring headers, work from the terminal with sudo so you can edit files under /etc/nginx. The simplest maintenance approach is to place every add_header directive inside a snippet and include it in each HTTPS server block.
First, create the snippet. Debian and Ubuntu keep reusable snippets under /etc/nginx/snippets/, while RHEL-based distributions (Fedora, Rocky Linux, AlmaLinux, CentOS Stream) use /etc/nginx/conf.d/ for configuration fragments:
For Ubuntu and Debian:
sudo nano /etc/nginx/snippets/security-headers.conf
For Fedora, Rocky Linux, AlmaLinux, and CentOS Stream:
sudo nano /etc/nginx/conf.d/security-headers.conf
Next, load the file inside every TLS-enabled server block so the headers apply to all responses. Adjust the include path based on your distribution and modify root to match your site:
Ubuntu and Debian server block:
server {
listen 443 ssl http2;
server_name example.com;
include snippets/security-headers.conf;
root /var/www/example.com;
index index.html;
}
Fedora, Rocky Linux, AlmaLinux, and CentOS Stream server block:
server {
listen 443 ssl http2;
server_name example.com;
include conf.d/security-headers.conf;
root /var/www/example.com;
index index.html;
}
Add every header directive to the snippet file you created so new server blocks inherit the same policy. Here is an enforcing example for Debian or Ubuntu under /etc/nginx/snippets/security-headers.conf (mirror the same directives if you store the file in /etc/nginx/conf.d/):
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; frame-ancestors 'none'; report-uri https://example.com/csp-report" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), fullscreen=(self), payment=(), usb=()" always;
add_header X-XSS-Protection "0" always;
Keep the CSP directive in Report-Only mode until you resolve violations, then swap it into the snippet to enforce it.
Always end security headers with the
alwaysparameter (for example,add_header ... always;) so NGINX attaches them even on 4xx or 5xx responses.
After creating the snippet and updating your server blocks, test the configuration syntax and reload NGINX:
sudo nginx -t && sudo systemctl reload nginx
Enable HTTP Strict Transport Security (HSTS) in NGINX
HSTS forces browsers to use HTTPS for your domain and any subdomains, preventing downgrade attempts during the first TLS handshake. Only add this header to the snippet after confirming HTTPS already works, since browsers cache the directive and will refuse HTTP connections for the entire max-age period. If you don’t have valid SSL certificates yet, start with securing NGINX with Let’s Encrypt first.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
- max-age=31536000: Tells browsers to cache the HTTPS requirement for 31536000 seconds (one year).
- includeSubDomains: Extends the rule to hosts like
cdn.example.comso you cannot accidentally serve insecure assets from a sibling domain.
Add
; preloadonly when every subdomain works over HTTPS and you have submitted the site to the HSTS preload list. Removing a premature preload entry can take weeks, so double-check certificates before opting in.
Additionally, redirect HTTP traffic to HTTPS to ensure clients receive the header on their first visit and stay protected afterwards:
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
Define a Content Security Policy (CSP)
CSP limits which domains can provide scripts, styles, fonts, images, and frames, shutting down most cross-site scripting vectors. Always begin with Content-Security-Policy-Report-Only so you log violations first, and include a reporting endpoint so the browser actually sends those errors. Avoid 'unsafe-inline' by using nonces or hashes on inline scripts, and if you depend on Google Fonts, allow both fonts.googleapis.com and fonts.gstatic.com.
Start with Report-Only mode to identify violations safely and capture reports:
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; frame-ancestors 'none'; report-uri https://example.com/csp-report" always;
Monitor your CSP report endpoint or logs for violations. Once you confirm no legitimate content is blocked, switch to the enforcing header (leave Report-Only in place while testing new resources):
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; frame-ancestors 'none'; report-uri https://example.com/csp-report" always;
- default-src ‘self’: Denies every external resource unless another directive allows it.
- script-src: Whitelist JavaScript CDNs or APIs you must load; prefer nonces or hashes over inline scripts.
- style-src: Keep styles on trusted hosts; avoid
'unsafe-inline'and migrate to hashed or nonce-based inline styles. - font-src: Include both
fonts.googleapis.comandfonts.gstatic.comwhen you use Google Fonts so CSS and font files load together. - report-uri: Send violations to an endpoint you monitor or an external service; add
report-towith aReport-Toheader if you use the Reporting API. - frame-ancestors: Replace X-Frame-Options when you need granular control over which domains may embed your site.
Handle Legacy Browsers with X-XSS-Protection
X-XSS-Protection is deprecated and ignored by modern Firefox and Chromium. Keep it disabled by default to avoid unpredictable filtering quirks, and only override it if you must support legacy Internet Explorer or old WebKit builds.
add_header X-XSS-Protection "0" always;
If you must serve legacy browsers, replace the directive above with the legacy fallback and confirm no legitimate content is blocked:
add_header X-XSS-Protection "1; mode=block" always;
The fallback tells supported legacy browsers to terminate rendering when reflected XSS is detected. Modern clients rely on CSP as their primary defense.
Block Clickjacking with X-Frame-Options
X-Frame-Options prevents clickjacking by blocking your site from loading inside hidden iframes layered over malicious pages. Start with DENY to block all framing; relax only if your application legitimately uses iframes internally.
add_header X-Frame-Options "DENY" always;
- DENY: Blocks all framing, safest for dashboards and forms.
- SAMEORIGIN: Permits frames on the same host only, useful for internal self-service portals.
When partners need to embed your content, use CSP’s frame-ancestors directive instead, since X-Frame-Options cannot whitelist specific external domains.
Stop MIME Sniffing with X-Content-Type-Options
Browsers sometimes guess a file type when the server mislabels it, which can cause scripts to execute unexpectedly. Fortunately, X-Content-Type-Options disables that sniffing step.
add_header X-Content-Type-Options "nosniff" always;
Combine this header with accurate types blocks in /etc/nginx/mime.types so static assets keep the correct Content-Type.
Control Referrer Data with Referrer-Policy
Referrer-Policy governs how much of the originating URL gets sent to other sites when users click outbound links. Limiting the data protects search terms, session identifiers, and other sensitive path segments from leaking.
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
- strict-origin-when-cross-origin: Sends the full URL to same-origin links, sends only the scheme and host to HTTPS targets, and strips the header when downgrading to HTTP.
- no-referrer: Shares nothing. Choose this for private admin portals or compliance-driven workloads.
- unsafe-url: Always sends the full URL and should be avoided.
Limit Browser Features with Permissions-Policy
Permissions-Policy (formerly Feature-Policy) restricts access to device capabilities such as cameras, microphones, USB, payment request API, or fullscreen. Locking unused features prevents embedded widgets or compromised scripts from presenting new prompts to your visitors.
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), fullscreen=(self), payment=(), usb=()" always;
This baseline blocks the most sensitive devices everywhere while still allowing fullscreen mode on your own origin. Additionally, expand the list with other APIs you don’t require, such as accelerometer, magnetometer, or clipboard-read.
Allow Trusted Services to Use Specific APIs
When a feature is required, explicitly list the origins that should retain access. While wildcards work, limit them to cases where you control the entire domain space.
add_header Permissions-Policy "geolocation=(self https://maps.example-app.com); fullscreen=(self https://player.example-cdn.com); payment=();" always;
- geolocation=(self https://maps.example-app.com): Keeps GPS calls inside your app and a trusted map provider.
- fullscreen=(self https://player.example-cdn.com): Lets your preferred streaming provider toggle fullscreen for embedded players.
- payment=(): Blocks Payment Request API so malicious scripts cannot pop up spoofed checkout windows.
When you need cross-origin isolation for features like SharedArrayBuffer, pair Permissions-Policy with
Cross-Origin-Opener-Policy: same-origin,Cross-Origin-Embedder-Policy: require-corp, and explicitCross-Origin-Resource-Policyheaders on static assets.
Verify Security Headers with curl
Use curl on any workstation or CI runner to confirm NGINX is serving the headers you expect. The -I flag (uppercase i) sends a HEAD request that returns only response headers without the body, making checks fast and scriptable. You can also apply grep to filter security headers from the output.
curl -I https://example.com
Alternatively, filter the response to highlight security-related fields when troubleshooting larger header sets:
curl -I https://example.com | grep -iE "(Strict-Transport-Security|Content-Security-Policy|X-Frame-Options|X-Content-Type-Options|X-XSS-Protection|Referrer-Policy|Permissions-Policy)"
Successful output looks similar to this sample:
HTTP/2 200 server: nginx strict-transport-security: max-age=31536000; includeSubDomains content-security-policy: default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; frame-ancestors 'none'; report-uri https://example.com/csp-report x-frame-options: DENY x-content-type-options: nosniff referrer-policy: strict-origin-when-cross-origin permissions-policy: camera=(), microphone=(), geolocation=(), fullscreen=(self), payment=(), usb=() x-xss-protection: 0
If a header is missing, first confirm the snippet is included in the correct server block, then run sudo nginx -T to print the merged configuration and verify directives are not overridden by nested location blocks. For help with reverse proxies, see how to create a reverse proxy in NGINX, which often requires special header handling.
Monitor Security Headers in CI/CD Pipelines
For production environments, automate header verification in your deployment pipeline. Use curl in scripts to confirm every required header is present before releasing new versions:
#!/bin/bash
URL=${1:-https://example.com}
RESPONSE=$(curl -I -s "$URL")
HEADERS=(Strict-Transport-Security Content-Security-Policy X-Frame-Options X-Content-Type-Options Referrer-Policy Permissions-Policy)
for HEADER in "${HEADERS[@]}"; do
if ! echo "$RESPONSE" | grep -qi "^${HEADER}:"; then
echo "ERROR: Missing $HEADER on $URL"
exit 1
fi
done
if echo "$RESPONSE" | grep -qi "^X-XSS-Protection:"; then
echo "NOTE: X-XSS-Protection is legacy. Keep it only for old browsers."
fi
echo "Security headers verified on $URL"
Additionally, use online security header scanners like securityheaders.com or Mozilla Observatory to audit your configuration periodically. These tools catch common misconfigurations and suggest improvements as new security practices emerge.
Retest after making changes to CSP rules, adding trusted external resources, or deploying new upstream services. Header misconfiguration is often silent, so automated verification prevents security regressions between deployments.
Conclusion
Your NGINX server now enforces HSTS, CSP, X-Frame-Options, and Permissions-Policy across all sites through a single reusable snippet. This defense-in-depth approach protects against cross-site scripting, clickjacking, and protocol downgrades without requiring application changes. Keep the snippet under version control, test CSP policies in Report-Only mode before enforcing, and automate header verification in your deployment pipeline.