jan-karel.com
Home / Security Measures / Web Security / Security Headers

Security Headers

Security Headers

Security Headers

Web Security Without Firefighting

Secure web development is not about extra friction, but about better defaults in design, code and release flow.

For Security Headers the benefit lies in context-bound output, browser restrictions and secure frontend baselines.

That makes security less of a separate afterthought check and more a standard quality of your product.

Immediate measures (15 minutes)

Why this matters

The core of Security Headers is risk reduction in practice. Technical context supports the choice of measures, but implementation and assurance are central.

Essential headers

Strict-Transport-Security (HSTS)

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

Tells the browser: "Use only HTTPS. Always. No exceptions." Prevents SSL-stripping attacks and unintended HTTP traffic.

Parameter Meaning
max-age=31536000 Browser remembers this for 1 year
includeSubDomains Applies to all subdomains as well
preload Allow inclusion in browser preload list

Note: HSTS preload is a one-way street. Once your domain is in the preload list, removal is a process of months. Test thoroughly first with a short max-age (e.g. 300 seconds).

X-Content-Type-Options

X-Content-Type-Options: nosniff

Prevents MIME sniffing: the browser trusts the Content-Type header and does not guess what a file is on its own. Without this header, an uploaded file that looks like an image but contains JavaScript can be executed as a script.

X-Frame-Options

X-Frame-Options: DENY

Prevents the page from being loaded in an <iframe>. Protects against clickjacking. Use SAMEORIGIN if your own site needs iframes.

Note: X-Frame-Options is gradually being replaced by CSP frame-ancestors. Set both for backward compatibility.

Referrer-Policy

Referrer-Policy: strict-origin-when-cross-origin

Limits what information the browser sends in the Referer header during navigation. Prevents internal URL structures, search queries or tokens from leaking to external sites.

Value Effect
no-referrer Never send a referer
same-origin Only when navigating within own domain
strict-origin Only the origin (no path) for cross-origin
strict-origin-when-cross-origin Full path for same-origin, only origin for cross-origin

Permissions-Policy

Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()

Blocks access to sensitive browser APIs. Prevents an XSS payload from activating the webcam, microphone or location.

Commonly blocked features:

Permissions-Policy: accelerometer=(), ambient-light-sensor=(), autoplay=(),
    camera=(), display-capture=(), encrypted-media=(), fullscreen=(self),
    geolocation=(), gyroscope=(), magnetometer=(), microphone=(),
    midi=(), payment=(), picture-in-picture=(), usb=()

X-XSS-Protection (deprecated)

X-XSS-Protection: 0

This header activated the XSS filter in older browsers. The filter has been removed from all modern browsers because it introduced vulnerabilities itself (XSS Auditor bypass). Set this header to 0 to prevent older browsers from activating the broken filter. Rely on CSP instead.

Content-Security-Policy in detail

CSP is the most powerful security header. It defines which sources the browser may load for each type of content. A well-configured CSP blocks XSS, data exfiltration and clickjacking in one go.

Basic structure

Content-Security-Policy: directive-1 value1 value2; directive-2 value3;

Important directives

Directive Controls Recommended value
default-src Fallback for all types 'self'
script-src JavaScript loading/execution 'self' (no 'unsafe-inline')
style-src CSS loading 'self' (preferably no 'unsafe-inline')
img-src Images 'self' data:
connect-src XHR, fetch, WebSocket 'self'
font-src Web fonts 'self'
frame-src Content in iframes 'none' or specific origins
frame-ancestors Who may iframe this page 'none'
base-uri <base> tag restriction 'self'
form-action Form action URLs 'self'
object-src Flash, Java applets 'none'
media-src Audio, video 'self'
worker-src Web Workers, Service Workers 'self'
report-uri Report violations URL to report endpoint
report-to Reporting API (modern) Group name

Nonce-based CSP

The safest approach for inline scripts: use a unique, per-request nonce.

Content-Security-Policy: script-src 'nonce-abc123xyz' 'strict-dynamic';
<!-- This script may execute (nonce matches) -->
<script nonce="abc123xyz">
  document.getElementById('menu').classList.toggle('open');
</script>

<!-- This script will be blocked (no nonce) -->
<script>alert('XSS')</script>

Implementation in Python/Flask:

import secrets
from flask import g, make_response

@app.before_request
def set_csp_nonce():
    g.csp_nonce = secrets.token_urlsafe(32)

@app.after_request
def add_csp_header(response):
    nonce = getattr(g, 'csp_nonce', '')
    csp = (
        f"default-src 'self'; "
        f"script-src 'nonce-{nonce}' 'strict-dynamic'; "
        f"style-src 'self'; "
        f"img-src 'self' data:; "
        f"frame-ancestors 'none'; "
        f"base-uri 'self'; "
        f"form-action 'self';"
    )
    response.headers['Content-Security-Policy'] = csp
    return response

In the template:

<script nonce="{{ g.csp_nonce }}">/* safe */</script>

strict-dynamic

script-src 'nonce-abc123' 'strict-dynamic';

strict-dynamic ensures that scripts loaded by a trusted (nonce-bearing) script are automatically also trusted. This makes it possible to dynamically load third-party libraries without putting every URL in the CSP.

report-uri and report-to

Content-Security-Policy: default-src 'self'; report-uri /csp-report;
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report;

Use Content-Security-Policy-Report-Only to test CSP without blocking content. The browser reports violations without enforcing them.

Example report endpoint (Flask):

@app.route('/csp-report', methods=['POST'])
def csp_report():
    report = request.get_json(force=True)
    app.logger.warning("CSP violation: %s", report)
    return '', 204

CSP migration path

  1. Start with Content-Security-Policy-Report-Only and monitor violations
  2. Fix all violations (move inline scripts to files, add nonces)
  3. Switch to Content-Security-Policy (enforcement)
  4. Keep report-uri/report-to active for continuous monitoring

The three essential flags

Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=Lax; Path=/
Flag Protects against Effect
Secure Network sniffing Cookie is only sent via HTTPS
HttpOnly XSS cookie theft JavaScript cannot read the cookie
SameSite=Lax CSRF Cookie is not sent with cross-site POST

SameSite in detail

Value GET cross-site POST cross-site Usage
Strict Not sent Not sent Banking, admin panels
Lax Sent Not sent General session cookies
None Sent Sent Only with Secure; for embeds/widgets

Note: SameSite=None requires the Secure flag. Without Secure, SameSite=None is ignored by the browser.

Cookie prefixes enforce additional restrictions at the transport level:

Set-Cookie: __Host-session=abc123; Secure; HttpOnly; SameSite=Lax; Path=/
Set-Cookie: __Secure-token=xyz789; Secure; HttpOnly; SameSite=Strict
Prefix Requirements
__Host- Secure flag, no Domain attribute, Path=/ — cookie is bound to exactly this domain
__Secure- Secure flag — cookie only goes over HTTPS

__Host- is the strongest option: it prevents an attacker on a subdomain from overwriting the cookie.

Domain and Path

Set-Cookie: session=abc; Domain=example.com; Path=/app
  • Without Domain: Cookie applies only to the exact domain (not subdomains)
  • With Domain=example.com: Cookie also applies to all subdomains — riskier
  • Path=/app: Cookie is only sent for requests to /app/*

Best practice: Omit Domain (or use the __Host- prefix) unless you explicitly need subdomains.

CORS headers

Cross-Origin Resource Sharing determines which external origins may call your API. Incorrect CORS configuration is one of the most common findings in web application assessments.

The headers

Header Purpose
Access-Control-Allow-Origin Which origin may read the response
Access-Control-Allow-Credentials May the browser send cookies/auth
Access-Control-Allow-Methods Allowed HTTP methods (preflight)
Access-Control-Allow-Headers Allowed request headers (preflight)
Access-Control-Expose-Headers Which response headers the browser may read
Access-Control-Max-Age How long preflight result is cached

Secure configuration

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true

Common mistakes

1. Reflecting the origin (the parrot)

# WRONG — reflects any origin
@app.after_request
def add_cors(response):
    origin = request.headers.get('Origin', '')
    response.headers['Access-Control-Allow-Origin'] = origin
    response.headers['Access-Control-Allow-Credentials'] = 'true'
    return response

This is functionally equivalent to no CORS protection: any site can call your API with the user's cookies.

2. Wildcard with credentials

# WRONG — browsers block this, but the intent shows a misunderstanding
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

The browser refuses this, but the fact that you're trying means you don't understand CORS.

3. Allowing null origin

# WRONG — 'null' origin comes from sandboxed iframes and data: URLs
Access-Control-Allow-Origin: null

An attacker can use a sandboxed iframe to send requests with Origin: null.

4. Regex without anchoring

# WRONG — also matches evil-example.com and example.com.evil.com
if re.search(r'example\.com', origin):
    allow(origin)

Always use an exact whitelist or anchor your regex:

# CORRECT — exact whitelist
ALLOWED_ORIGINS = {'https://app.example.com', 'https://admin.example.com'}

@app.after_request
def add_cors(response):
    origin = request.headers.get('Origin', '')
    if origin in ALLOWED_ORIGINS:
        response.headers['Access-Control-Allow-Origin'] = origin
        response.headers['Access-Control-Allow-Credentials'] = 'true'
    return response

Implementation per web server

nginx

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

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 "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header X-XSS-Protection "0" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;

Use in your server block:

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

    include snippets/security-headers.conf;

    # ... rest of the configuration
}

Note: the always keyword ensures that headers are also set on error pages (4xx, 5xx). Without always, headers are missing on error responses — exactly the moment you need them the most.

Apache

# /etc/apache2/conf-available/security-headers.conf

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 "camera=(), microphone=(), geolocation=(), payment=()"
Header always set X-XSS-Protection "0"
Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"

Activate with:

sudo a2enmod headers
sudo a2enconf security-headers
sudo systemctl reload apache2

Implementation per framework

Flask (Python) — flask-talisman

from flask import Flask
from flask_talisman import Talisman

app = Flask(__name__)

csp = {
    'default-src': "'self'",
    'script-src': "'self'",
    'style-src': "'self'",
    'img-src': "'self' data:",
    'frame-ancestors': "'none'",
    'base-uri': "'self'",
    'form-action': "'self'",
}

Talisman(
    app,
    content_security_policy=csp,
    strict_transport_security=True,
    strict_transport_security_max_age=31536000,
    strict_transport_security_include_subdomains=True,
    session_cookie_secure=True,
    session_cookie_http_only=True,
    session_cookie_samesite='Lax',
)

Django — built-in settings

# settings.py

# HSTS
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True

# HTTPS
SECURE_SSL_REDIRECT = True

# Headers
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_BROWSER_XSS_FILTER = False  # X-XSS-Protection: 0
X_FRAME_OPTIONS = 'DENY'
SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin'

# Cookies
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_HTTPONLY = True

# CSP via django-csp
CSP_DEFAULT_SRC = ("'self'",)
CSP_SCRIPT_SRC = ("'self'",)
CSP_STYLE_SRC = ("'self'",)
CSP_IMG_SRC = ("'self'", "data:")
CSP_FRAME_ANCESTORS = ("'none'",)

Express (Node.js) — helmet

const express = require('express');
const helmet = require('helmet');

const app = express();

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      styleSrc: ["'self'"],
      imgSrc: ["'self'", "data:"],
      frameAncestors: ["'none'"],
      baseUri: ["'self'"],
      formAction: ["'self'"],
    },
  },
  strictTransportSecurity: {
    maxAge: 31536000,
    includeSubDomains: true,
    preload: true,
  },
  referrerPolicy: {
    policy: 'strict-origin-when-cross-origin',
  },
}));

// Cookie flags (express-session)
app.use(session({
  cookie: {
    secure: true,
    httpOnly: true,
    sameSite: 'lax',
    maxAge: 3600000,
  },
}));

Test tools

Automated scanners

Tool URL / Command What it tests
SecurityHeaders.com https://securityheaders.com/?q=example.com All security headers, grading A-F
Mozilla Observatory https://observatory.mozilla.org Headers + TLS + best practices
CSP Evaluator https://csp-evaluator.withgoogle.com CSP policy for weaknesses

Manual testing

# View all response headers
curl -I https://example.com

# Check a specific header
curl -sI https://example.com | grep -i content-security-policy

# Test CSP with a report-only header
curl -sI https://example.com | grep -i content-security-policy-report

Browser DevTools

  1. Open DevTools → Network tab
  2. Click on a request → view Response Headers
  3. Open ConsoleCSP violations appear as errors
  4. Application tab → Cookies → check flags per cookie

Checklist

Header Value Priority
Strict-Transport-Security max-age=31536000; includeSubDomains Critical
X-Content-Type-Options nosniff Critical
X-Frame-Options DENY High
Content-Security-Policy Restrictive, no unsafe-inline Critical
Referrer-Policy strict-origin-when-cross-origin High
Permissions-Policy Disable camera, mic, geo Medium
X-XSS-Protection 0 Medium
Cookie: Secure On all cookies Critical
Cookie: HttpOnly On session cookies Critical
Cookie: SameSite Lax or Strict High
Cookie: __Host- prefix On session cookies Recommended
CORS Explicit whitelist, never reflected origin High

The beauty of security headers is that they solve almost everything for free. Clickjacking? One header. MIME sniffing? One header. XSS? One (larger) header. Cross-origin data theft? Two headers. Cookie hijacking? Three flags. All together maybe fifteen lines of configuration.

But we don't do it. Instead we build a Web Application Firewall with three thousand rules, put a reverse proxy in front of it with another five hundred rules, and hire a Security Operations Center that monitors 24/7 whether someone types <script> into a search field. That costs a hundred thousand euros per year. The headers cost zero euros and five minutes.

And when we are asked "Why are there no security headers on your application?", the answer is: "That was on the backlog." Behind "move the button half a pixel to the left" and "update the Easter egg for Easter." Because priorities.

Summary

Security headers are the simplest and cheapest defense layer you can implement. CSP protects against XSS and data exfiltration. HSTS enforces HTTPS. Cookie flags prevent session theft. CORS prevents unauthorized API access.

It takes five minutes to set them up. It takes months to repair the damage if you don't.

In the next chapter we look at what happens before those headers are in place: how do you validate input and encode output to prevent vulnerabilities at the source?

Op de hoogte blijven?

Ontvang maandelijks cybersecurity-inzichten in je inbox.

← Web Security ← Home