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 by installing NGINX (Ubuntu, Debian, Fedora), 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
Before integrating the snippet, verify the file syntax is correct:
sudo nginx -t
nginx: configuration file /etc/nginx/nginx.conf test is successful
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:
Before modifying your server block configuration, create a backup:
sudo cp /etc/nginx/sites-available/example.com /etc/nginx/sites-available/example.com.bak. This allows you to restore the working configuration if errors occur.
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.
When you add
add_headerdirectives inside alocationblock, NGINX discards all server-level headers for that location. To preserve server-level security headers in locations with their own headers, include the snippet again:include snippets/security-headers.conf;(orinclude conf.d/security-headers.conf;on RHEL-based systems) inside each affected location block.
After creating the snippet and updating your server blocks, test the configuration syntax and reload NGINX:
sudo nginx -t && sudo systemctl reload nginx
The syntax test confirms your configuration is valid before reloading:
nginx: configuration file /etc/nginx/nginx.conf test is successful nginx: configuration file /etc/nginx/nginx.conf test successful
If the test passes, NGINX reloads gracefully without dropping active connections.
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. - always: Ensures the header appears even on error responses (4xx, 5xx), preventing downgrade attacks during error conditions.
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 (you can keep Report-Only alongside enforcing to test policy changes before fully deploying them):
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;
Key CSP directives explained:
- default-src ‘self’: Denies every external resource unless another directive explicitly allows it. This fail-closed approach prevents loading unexpected content.
- script-src: Allow JavaScript CDNs or APIs you must load. Prefer nonces (
'nonce-random123') or hashes ('sha256-...') over'unsafe-inline'for inline scripts. - style-src: Keep styles on trusted hosts. Avoid
'unsafe-inline'by migrating inline styles to external stylesheets or using nonces/hashes. - img-src: The
data:scheme allows inline images (base64-encoded) which are common in modern web apps and email templates. - font-src: Include both
fonts.googleapis.com(CSS delivery) andfonts.gstatic.com(font file hosting) when using Google Fonts. Both domains are required for the service to work. - frame-ancestors: Controls which sites can embed your page in iframes. Setting this to
'none'prevents all embedding (similar toX-Frame-Options: DENY). Use specific origins likeframe-ancestors 'self' https://trusted-partner.comwhen you need to allow embedding from known partners. - report-uri: Sends violation reports to an endpoint you monitor. Consider also adding
report-towith aReport-Toheader for newer browsers (the Reporting API provides richer violation context).
X-XSS-Protection (Deprecated Header)
X-XSS-Protection is a deprecated header that modern browsers (Firefox, Chrome, Edge) no longer support or use. It was originally designed to enable browser-side XSS filtering, but research showed the filtering mechanism itself could be exploited to create new vulnerabilities.
Recommended configuration (disable the header):
add_header X-XSS-Protection "0" always;
Setting the value to 0 explicitly disables any XSS filtering in browsers that still respect this header. Modern browsers rely on Content Security Policy (CSP) as the primary XSS defense mechanism, which provides more granular and reliable protection.
Legacy Internet Explorer Support (Not Recommended)
If you must serve Internet Explorer 8-11 or very old WebKit-based browsers (Safari 5, old Android), you can enable the legacy filtering mode. Test thoroughly before deploying, as the filter can cause false positives and block legitimate content:
add_header X-XSS-Protection "1; mode=block" always;
This configuration tells legacy browsers to block page rendering entirely when reflected XSS patterns are detected. Given the security issues with this approach and the fact that IE 11 reached end-of-life in June 2022, most sites should use the disabled configuration ("0") instead.
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 allow 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. For additional hardening, consider enabling gzip compression in NGINX and rate limiting in NGINX to improve performance and prevent abuse.
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"
Expected output when all headers are present:
NOTE: X-XSS-Protection is legacy. Keep it only for old browsers. Security headers verified on https://example.com
If a required header is missing, the script exits with an error:
ERROR: Missing Strict-Transport-Security on https://example.com
Integrate this script into your CI/CD pipeline (GitHub Actions, GitLab CI, Jenkins) to block deployments when security headers are misconfigured or missing.
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.
Troubleshooting Common Security Header Issues
Security header configuration errors are often silent, as headers simply don’t appear in responses or CSP violations break functionality without obvious error messages. Use these diagnostic steps to identify and fix common problems.
Headers Missing from All Responses
If curl -I shows no security headers at all, check these common causes in order:
1. Verify the snippet file exists and is readable:
ls -la /etc/nginx/snippets/security-headers.conf
Or on RHEL-based systems:
ls -la /etc/nginx/conf.d/security-headers.conf
If the file doesn’t exist or has wrong permissions, recreate it following the “Create a Reusable Security Headers Snippet” section above.
2. Confirm the include path in your server block is correct:
sudo nginx -T | grep -A 5 "server_name example.com"
Look for the include snippets/security-headers.conf; or include conf.d/security-headers.conf; directive inside the HTTPS server block (port 443). If it’s missing, add it and reload NGINX.
3. Check for NGINX configuration syntax errors:
sudo nginx -t
Syntax errors prevent NGINX from reloading successfully. If you see errors, fix them before proceeding. Common syntax issues include missing semicolons, unmatched quotes in CSP directives, or typos in header names.
Headers Missing from Specific Paths
If headers appear on some URLs but not others, you’ve likely hit NGINX’s add_header inheritance behavior. When a location block contains any add_header directive, NGINX ignores all server-level headers for that location.
Diagnosis: Check if the affected path has a location block with add_header:
sudo nginx -T | grep -B 5 -A 10 "location /api"
If you see add_header inside the location block, security headers from the server level won’t apply.
Fix: Include the security headers snippet inside the location block:
location /api {
include snippets/security-headers.conf;
add_header Cache-Control "no-cache" always;
proxy_pass http://backend;
}
This ensures security headers appear alongside location-specific headers.
Content Security Policy Blocks Legitimate Resources
If your site loads but images, scripts, or styles fail after enabling CSP, check the browser’s developer console (F12) for violation reports:
Refused to load the script 'https://analytics.example.com/script.js' because it violates the following Content Security Policy directive: "script-src 'self' https://cdn.example.com".
The error message shows which directive blocked the resource (script-src) and which source tried to load (https://analytics.example.com).
Fix: Add the missing origin to the appropriate CSP directive in your snippet file:
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.example.com https://analytics.example.com; ..." always;
Test with Content-Security-Policy-Report-Only first to confirm the fix doesn’t break anything else, then switch to the enforcing header.
Headers Appear Duplicated in Responses
If curl -I shows the same header twice with different values, you’ve defined the header in multiple places (server block and snippet, or server and location).
Diagnosis: Search the merged configuration for duplicate directives:
sudo nginx -T | grep "add_header Strict-Transport-Security"
If you see the same header defined in multiple contexts, remove the redundant definitions.
Fix: Keep security headers only in the snippet file, not in individual server or location blocks. Remove any standalone add_header directives for security headers and rely solely on the included snippet.
HSTS Preload Submission Rejected
If you’re trying to submit your site to the HSTS preload list but the submission fails, verify these requirements:
- HSTS header includes
max-ageof at least 31536000 (one year) - HSTS header includes
includeSubDomainsdirective - HSTS header includes
preloaddirective - All subdomains serve valid HTTPS certificates
- HTTP (port 80) redirects to HTTPS before serving content
Test your configuration at hstspreload.org before submitting. The site provides detailed error messages for each failed requirement.
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.