Security Headers #

When securing a web application, we often focus too much on locking down the server side (like databases, backends, and network ports) and forget the other main attack surface: the user’s browser side (client-side). Attacks like Cross-Site Scripting (XSS), Clickjacking, and MIME sniffing hijacking target security holes in the browser’s interaction with our web pages.

One of the easiest and most efficient ways to protect users from these attacks is applying HTTP Security Headers in Nginx. These headers are a set of security instructions that Nginx inserts into every HTTP response to enable built-in protection features on modern browsers. In this article, we’ll dissect the functionality of each major security header, how to build a solid Content Security Policy (CSP), hide the server software identity, and organize the settings into reusable snippet files.

How Do Security Headers Protect Browsers? #

Modern browsers are designed with various sophisticated internal security systems. However, by default, browsers behave neutrally and passively to preserve web history compatibility. By sending HTTP Security Headers, Nginx acts as a strict instructor that forces browsers to tighten their behavior to protect user privacy and data integrity.

Each header acts as a shield for a specific attack hole. Let’s dissect them one by one:


X-Frame-Options: Fending Off Clickjacking Attacks #

Clickjacking is a deception technique where an attacker wraps our web page inside a transparent iframe element on their website. Users think they’re clicking a prize button on the attacker’s site, when in reality they’re unknowingly clicking a money transfer button or an account deletion button on our site hidden underneath.

To prevent our site from being put into an iframe against our will, we use the X-Frame-Options header:

# Option 1: Totally forbid our site from being put into an iframe by anyone
add_header X-Frame-Options "DENY" always;

# Option 2: Allow iframes only if the caller is from the same domain (General Recommendation)
add_header X-Frame-Options "SAMEORIGIN" always;
  • DENY: The best choice if our application never needs iframe loading features on other pages.
  • SAMEORIGIN: The ideal choice for most modern web applications that still need to load internal pages as internal pop-up dialogs.
  • Why the always parameter matters: By default, Nginx only adds headers on responses with success status codes (like 200 or 301). By including the always keyword, Nginx guarantees this security header is still sent even on error responses (like 404 or 500), which are also vulnerable to attacks.

X-Content-Type-Options: Preventing MIME Sniffing Exploits #

MIME Sniffing is a browser feature that guesses file types based on content, ignoring the official Content-Type header sent by the server.

This hole is very dangerous if our site allows users to upload custom text or image files. For example, an attacker can upload a plain text file containing malicious JavaScript code (e.g., a cookie-stealing script). If a visitor’s browser tries to guess the file type and executes it as JavaScript, an XSS attack has succeeded.

We lock down this browser guessing behavior with the directive:

# Force browsers to obey the server's official Content-Type
add_header X-Content-Type-Options "nosniff" always;

By setting nosniff, browsers are forced to comply with the server’s response header parameters. If Nginx sends a file as text/plain, browsers refuse to execute it as a script.


Referrer-Policy: Controlling Origin URL Leaks #

When a user clicks an external link on our page to go to another site, browsers by default send the full origin URL in the HTTP Referer header (e.g., Referer: https://example.com/account/change-profile?token=secret). This can leak sensitive internal URLs or query tokens to external servers owned by others.

We limit the volume of this information leakage using Referrer-Policy:

# Recommendation: Send the full URL only for internal domains;
# Send only the origin domain (without path) for external HTTPS;
# Send nothing when moving to insecure plain HTTP.
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

The strict-origin-when-cross-origin value provides the best balance between protecting user data privacy and maintaining legitimate traffic tracking analytics functions (analytics tools).


X-XSS-Protection: Security for Older Browsers #

This header is designed to enable the built-in XSS filter on older browsers (like Internet Explorer 8+ and old Google Chrome versions). Modern browsers (like new Chrome and Firefox) have now removed support for this header because it’s been replaced by the far superior Content Security Policy, but inserting it is still useful for backward compatibility:

# Enable the XSS filter, immediately block full page rendering if an attack is detected
add_header X-XSS-Protection "1; mode=block" always;

Permissions-Policy: Restricting Browser Hardware Feature Access #

Permissions-Policy (formerly named Feature-Policy) lets us control which browser API features are allowed to run on our page or inside iframes we load. This is very important for minimizing the attack surface if our site gets hacked.

For example, if our application is just a simple text blog, there’s no reason for browsers to allow access to the camera, microphone, or credit card payment modules:

# Disable sensitive feature access for all origins
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;

Content Security Policy (CSP): The Strongest XSS Defense Wall #

Content Security Policy (CSP) is the most powerful yet the most complex security header to configure. CSP acts as a gatekeeper that strictly tells browsers where assets (like JavaScript scripts, CSS files, images, fonts, and API connections) are allowed to be loaded and executed from.

By default, browsers execute any JavaScript code they find in an HTML document, including malicious code injected by attackers (XSS attacks). With CSP active, browsers block every script whose origin isn’t registered in our safe list.

Here’s an overview of how CSP filters script origins:

flowchart TD
    Request["Browser Finds a Script Tag"] --> CheckCSP{"Is the Script Source in the CSP List?"}
    CheckCSP -->|Yes: from our own domain or 'self'| Exec["Execute Script (Safe)"]
    CheckCSP -->|Yes: from a registered external CDN| Exec
    CheckCSP -->|No: inline script or unknown domain| Block["Block Execution & Send a CSP Report (Prevent XSS!)"]
    
    classDef success fill:#10b981,stroke:#059669,color:#ffffff;
    classDef danger fill:#ef4444,stroke:#dc2626,color:#ffffff;
    class Exec success;
    class Block danger;

Safe CSP Policy Writing Patterns #

Designing CSP requires carefulness, because if we forget to register one external asset domain, that feature will immediately break in users’ browsers.

Here’s an example of a recommended basic CSP policy:

add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://apis.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://api.stripe.com; frame-ancestors 'none';" always;
  • default-src 'self': The default fallback if other rules aren’t specifically defined is to only allow resources from our own domain ('self').
  • script-src 'self' https://apis.google.com: JavaScript may only be loaded from our domain and the official Google API CDN.
  • style-src 'self' 'unsafe-inline': CSS files may be loaded from our domain. The 'unsafe-inline' usage is sometimes still needed if our frontend framework writes CSS styles directly in HTML tags.
  • frame-ancestors 'none': Prevents our site from being wrapped in an iframe by anyone (acting as a modern replacement for X-Frame-Options).

[!TIP] Before applying CSP directly to production users, use the Content-Security-Policy-Report-Only header first. Browsers won’t block assets violating the policy; instead, they’ll only send error reports to the endpoint URL we specify for analysis and configuration fixing.


Hiding the Nginx Server Identity #

Attacker reconnaissance practices usually begin with finding out the type of web server and the specific version we’re using (e.g., Server: nginx/1.18.0). By knowing the system version, attackers can search the internet for security exploit databases matching that version.

We must minimize this information exposure:

1. Hide the Nginx Version (server_tokens off;) #

By default, Nginx displays version information on HTTP response headers and built-in error pages (like the 404 error page). We can turn off this version display with the server_tokens directive:

# Put it inside the http block
server_tokens off;

Once enabled, our server header will only read Server: nginx without displaying the version number behind it.

2. Hide the Server Name Completely (Using the Headers More Module) #

If we want to go further by completely removing the Server: nginx text or replacing it with a custom name (e.g., Server: WebServer), we need a third-party module called headers-more:

# Install the headers-more module on Ubuntu/Debian
sudo apt install libnginx-mod-http-headers-more-filter -y

Then in the Nginx configuration:

# Remove the Server header completely from HTTP responses
more_clear_headers 'Server';

# Or replace its value dynamically
# more_set_headers 'Server: MyCustomSecureServer';

Organizing Security Headers in Modular Snippets #

To keep our virtual host configuration clean and easy to maintain, we highly recommend grouping all security header declarations into one separate file, e.g., /etc/nginx/snippets/security-headers.conf:

# /etc/nginx/snippets/security-headers.conf

# Iframe Clickjacking Protection
add_header X-Frame-Options "SAMEORIGIN" always;

# Prevent MIME Sniffing
add_header X-Content-Type-Options "nosniff" always;

# Control Referrer Leaks
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

# XSS Filter Compatibility
add_header X-XSS-Protection "1; mode=block" always;

# Browser Hardware Feature Restrictions
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;

# Remove server version information
server_tokens off;

Now, in every Nginx server block virtual host configuration file, we simply call that snippet file with one include directive line:

server {
    listen 443 ssl;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # Include all Security Headers
    include snippets/security-headers.conf;

    root /var/www/html;
    
    location / {
        try_files $uri $uri/ =404;
    }
}

After reloading the configuration (sudo systemctl reload nginx), we can validate whether all headers have been successfully installed using a free analysis service like securityheaders.com.


Safe CSP Alternatives: Using CSP Nonce and Hash #

When applying a strict Content Security Policy (CSP), one of our biggest obstacles is the presence of Inline Scripts (JavaScript code written directly inside <script> HTML tags) or Inline Styles (CSS code inside <style> tags or style="..." attributes).

By default, a secure CSP forbids inline script execution to prevent hackers from injecting malicious code. The lazy way to bypass this is adding the 'unsafe-inline' rule to the CSP, but this nullifies 90% of CSP’s XSS protection function.

Two safe industry-standard solutions to solve this problem are:

1. CSP Nonce (Number Used Once) #

CSP Nonce works by generating a unique random string (cryptographic token) for each page request. The server inserts this nonce into the CSP header and every valid HTML script tag. Browsers only execute inline scripts whose nonce value matches the header.

In Nginx, if we use Nginx Plus or a custom generator module, we can generate dynamic nonces. However, for regular Nginx open source, dynamic nonces are generally set directly from our backend application side (e.g., Node.js/Express or Laravel) by forwarding the CSP header to Nginx, or generated in Nginx using the $request_id variable:

# Example of a simple Nonce implementation in Nginx using the request ID
# (Note: The $request_id value is converted to Base64 before being used as a nonce)
# add_header Content-Security-Policy "script-src 'self' 'nonce-$request_id';" always;

In the HTML document, our backend must write:

<script nonce="SERVER_REQUEST_ID_VALUE">
    console.log("This inline script is valid and will be executed by the browser!");
</script>

2. CSP Hash (SHA-256/384/512) #

If our inline script is static (its value never changes), we don’t need a dynamic token. We just calculate the SHA-256 hash of the entire text inside the <script> tag, then include that hash in the CSP header.

Example: We have the following inline script in HTML:

<script>alert('Hello World');</script>

The SHA-256 hash of the text alert('Hello World'); (including spaces precisely) is sha256-a7y.... We register this hash in Nginx:

add_header Content-Security-Policy "script-src 'self' 'sha256-a7yGdhfF7hsg8DhsYshsDhDgs8sHdjs=';" always;

Browsers read that hash, independently calculate the hash of the inline script on the page, and only allow execution if the hash matches.


CORS (Cross-Origin Resource Sharing) vs Security Headers #

We’re often confused distinguishing between Security Headers and CORS Headers (Access-Control-Allow-Origin, etc.). Both have opposite protection direction philosophies:

  • Security Headers: Restrict what browsers are allowed to do when loading our site’s pages (protecting our site and users from exploits).
  • CORS Headers: Provide relaxation of browser security rules (Same-Origin Policy) so different external domains are allowed to fetch data/APIs from our server.

If we build an Nginx API server, we must configure CORS safely, only allowing our official web domain origins to access the API:

server {
    listen 443 ssl;
    server_name api.example.com;

    location / {
        # Allow only our official frontend to fetch API data
        add_header Access-Control-Allow-Origin "https://www.example.com" always;
        add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;

        # Handle OPTIONS preflight requests instantly at Nginx without burdening the backend
        if ($request_method = 'OPTIONS') {
            add_header Access-Control-Allow-Origin "https://www.example.com" always;
            add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
            add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
            add_header Access-Control-Max-Age 1728000;
            add_header Content-Type 'text/plain; charset=utf-8';
            add_header Content-Length 0;
            return 204;
        }

        proxy_pass http://api_backend;
    }
}

Summary #

  • Security Headers Are Mandatory: Enable at minimum the X-Frame-Options, X-Content-Type-Options, and Referrer-Policy headers on every production Nginx web server to close common client-side attack holes.
  • Always Use the always Parameter: Make sure the always keyword is included in every add_header directive so protection is still sent when the server produces error responses.
  • Use CSP Report Only: Apply the CSP policy gradually using Report-Only mode first to minimize the risk of a broken website due to unregistered external assets.
  • Hide the Server Version: Use server_tokens off; to make it harder for attackers to know our active Nginx system version.
  • Modular Configuration: Consolidate all security header directives into a modular snippet file so it’s easy to include across all virtual hosts without code duplication.

← Previous: DoS Protection   Next: Access Log →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact