DEV Community

Nils Eriksson
Nils Eriksson

Posted on

Your Web Server Is Leaking: The Security Headers Most Developers Forget

Your Web Server Is Leaking: The Security Headers Most Developers Forget

Meta: Learn the critical HTTP security headers developers skip. CSP, HSTS, X-Frame-Options—what they do, why they matter, and how to implement them. Production checklist included.

Keyword: HTTP security headers checklist, Content-Security-Policy, HSTS, web server security


I walked into a consulting call last week. Nice company, decent traffic, been running for three years. First thing I did: dumped their HTTP headers.

Nothing. Zero security headers. Not even the basics.

The CTO looked at me confused. "Wait, that matters?"

Yeah. It matters. And judging by how often I see this, I'm not the only one cleaning up after this particular oversight. So let's fix it.

Why Headers? Because Browsers Need Rules

Here's the thing most developers don't get: your browser is inherently trusting. It'll run whatever JavaScript you give it. It'll load resources from wherever. It'll let parent pages mess with your iframe. None of that is bad in isolation—it's flexibility. But without guardrails, it's a security disaster.

HTTP security headers are those guardrails. They're instructions you send back with every HTTP response that tell the browser: "Here are the rules for my site. Follow them."

The beauty is they cost nothing. No performance hit. No infrastructure change. Just a few lines of config. And yet somehow, most sites don't have them.

Content-Security-Policy: The Heavyweight Champion

If you only implement one header, make it this one. CSP is the most powerful and most misunderstood security header out there.

What it does: whitelists which sources the browser is allowed to load resources from. Inline scripts? Blocked by default. External stylesheets from random CDNs? Nope. Iframes pointing to sketchy domains? Denied.

Why it matters: if an attacker injects malicious JavaScript into your page (through a vulnerable plugin, a compromised dependency, bad input sanitization—pick one), CSP stops them cold. They can't run their code. They can't exfiltrate data.

The basic policy:

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{random}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'
Enter fullscreen mode Exit fullscreen mode

Let's break this down:

  • default-src 'self' — everything comes from your own origin by default
  • script-src 'self' 'nonce-{random}' — only your own scripts run, plus inline scripts with a nonce (more on that)
  • style-src 'self' 'unsafe-inline' — this is the compromise line. Ideally, no inline styles, but CSS-in-JS and older frameworks need this
  • img-src 'self' data: https: — load images from your origin, data URIs, or HTTPS anywhere
  • connect-src 'self' — XHR/fetch only to your origin (blocks exfiltration)
  • frame-ancestors 'none' — don't let anyone iframe your site

About nonces: if you need inline scripts (and most modern SPAs do), use a nonce instead of 'unsafe-inline'. Generate a cryptographic random string, include it in your CSP header, and add it to your script tags:

<script nonce="aj5k9s8d7f6">
  console.log('This runs because I have the nonce');
</script>
Enter fullscreen mode Exit fullscreen mode

The nonce should be unique per request. Yeah, it's extra work, but it means you can allow specific inline scripts while blocking injected ones.

Pro tip: start with Content-Security-Policy-Report-Only header first. Same policy, but violations get logged instead of blocked. Let it run for a week, check your logs, fix the issues, then flip to the real header. I've seen too many sites go live with a broken CSP and break half their features.

Strict-Transport-Security: Never Go Back to HTTP

HSTS is simple: tell the browser "always use HTTPS for this domain, no exceptions."

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Enter fullscreen mode Exit fullscreen mode

What this means:

  • max-age=31536000 — remember this for one year (in seconds)
  • includeSubDomains — apply this to ALL subdomains
  • preload — I want to be in the HSTS preload list (a hardcoded browser list of sites that always use HTTPS)

Why it matters: if someone intercepts the initial HTTP request before they get redirected to HTTPS, an attacker can steal cookies and credentials. HSTS kills that vector—the browser refuses to connect via HTTP at all.

Word of warning: includeSubDomains is essential for security, but it's also a gun pointed at your foot. Every subdomain has to support HTTPS. If you have some ancient mail.yoursite.com still running on HTTP, HSTS will break it. I've seen companies shoot themselves in the foot here. Get your house in order first, then enable HSTS.

Also: preload is optional but good. You're volunteering for a hardcoded list in Chrome, Firefox, Safari, and others. Once you're in, you can't get out quickly—it's essentially permanent. Only do this if you're 100% committed to HTTPS forever.

X-Content-Type-Options: Stop MIME Type Sniffing

One line. That's it.

X-Content-Type-Options: nosniff
Enter fullscreen mode Exit fullscreen mode

What it does: tells browsers "only run files as the MIME type I say they are." Without this, if you serve a file as text/plain that's actually JavaScript, older browsers will sniff it and run it anyway.

This is old-school security theatre by now—modern browsers are smarter. But it costs nothing, so add it.

X-Frame-Options (and frame-ancestors in CSP)

Two ways to say the same thing:

X-Frame-Options: DENY
Enter fullscreen mode Exit fullscreen mode

Or via CSP:

Content-Security-Policy: frame-ancestors 'none'
Enter fullscreen mode Exit fullscreen mode

What it does: tells the browser "don't let anyone embed this page in an iframe." This blocks clickjacking attacks where an attacker overlays a transparent iframe over your login button, tricks users into clicking it, and steals their credentials.

If you DO need to allow certain sites to iframe you:

X-Frame-Options: ALLOW-FROM https://trusted-partner.com
Enter fullscreen mode Exit fullscreen mode

But ALLOW-FROM is deprecated. Use CSP instead:

Content-Security-Policy: frame-ancestors 'self' https://trusted-partner.com
Enter fullscreen mode Exit fullscreen mode

CSP is the modern way. Drop both headers if you're CSP-only, but set both if you want to support older browsers.

Referrer-Policy: Control What You Leak

When you link to another site, browsers send the Referer header (yes, it's a typo from 1994—we're stuck with it). That header includes your full URL, including query parameters.

If your URLs contain sensitive data, that's leaking. Use:

Referrer-Policy: strict-origin-when-cross-origin
Enter fullscreen mode Exit fullscreen mode

What this means: only send the origin (domain) when linking cross-site, send the full URL within your own site. It's the sensible default that doesn't break anything.

Other options:

  • no-referrer — tell the browser nothing about where we're linking from (might break some analytics)
  • same-origin — only send referrer for same-site requests
  • strict-origin — only send the origin, never the full path

I use strict-origin-when-cross-origin as the default. It's protective without being paranoid.

Permissions-Policy: Lock Down Browser APIs

This one's newer, and most developers ignore it. Bad idea.

Permissions-Policy: microphone=(), camera=(), geolocation=(), payment=()
Enter fullscreen mode Exit fullscreen mode

What it does: tells the browser "my page doesn't need these APIs." If a third-party script somehow loads and tries to access your user's microphone, camera, or location, it gets denied.

If you actually need one of these (video calls, location services), be specific:

Permissions-Policy: microphone=(self "https://trusted-service.com"), camera=(self), geolocation=()
Enter fullscreen mode Exit fullscreen mode

This is especially important if you're running third-party ads or embeds. You're not denying the APIs outright; you're denying them to untrusted code.

How to Actually Implement This

Nginx

Drop this in your server block:

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

    # TLS config here...

    # Security Headers
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "DENY" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "microphone=(), camera=(), geolocation=(), payment=()" always;
    add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'nonce-{random}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'" always;

    location / {
        proxy_pass http://backend;
    }
}
Enter fullscreen mode Exit fullscreen mode

Note the always directive—that ensures headers get sent even on error responses (like 404).

Apache

In your .htaccess or VirtualHost:

<IfModule mod_headers.c>
    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
    Header always set X-Content-Type-Options "nosniff"
    Header always set X-Frame-Options "DENY"
    Header always set Referrer-Policy "strict-origin-when-cross-origin"
    Header always set Permissions-Policy "microphone=(), camera=(), geolocation=(), payment=()"
    Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'nonce-{random}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'"
</IfModule>
Enter fullscreen mode Exit fullscreen mode

Reload your server and you're done.

Testing: Verify You Actually Did It

Open your site in a browser, hit F12, go to the Network tab, click the main page request, and scroll down to Response Headers. You should see all the headers you just added.

For a quick audit, hit securityheaders.com and enter your domain. It'll grade you and tell you what you're missing. It's free and it'll give you a reality check.

The Checklist

Before you deploy, verify:

  • [ ] Strict-Transport-Security set with includeSubDomains
  • [ ] X-Content-Type-Options: nosniff
  • [ ] X-Frame-Options: DENY or CSP frame-ancestors 'none'
  • [ ] Content-Security-Policy with sensible defaults (start with Report-Only)
  • [ ] Referrer-Policy set to strict-origin-when-cross-origin or stricter
  • [ ] Permissions-Policy locks down microphone, camera, geolocation, payment
  • [ ] Test with securityheaders.com and browser devtools
  • [ ] Monitor CSP violations if you're using Report-Only

The End

I walked out of that client call with a config file and a promise to check back in a week. Week later, they're running an A rating on securityheaders.com. Their users don't see the difference, but now an attacker would have a much harder time. That's the whole point.

Security headers aren't sexy. They don't ship features. But they're the security equivalent of wearing a seatbelt—cheap, effective, and something you'll regret not doing if something goes wrong.

Go add them. Your future self will thank you.


Nils Eriksson is a Stockholm-based security consultant. He writes about web security, bad practices, and the occasional existential crisis caused by production deployments.

Top comments (0)