jan-karel.nl
Home / Securitymaatregelen / Webbeveiliging / Security Headers

Security Headers

Security Headers

Websecurity Zonder Nablussen

Veilige webontwikkeling draait niet om extra frictie, maar om betere defaults in ontwerp, code en releaseflow.

Voor Security Headers ligt de winst in context-gebonden output, browserbeperkingen en veilige frontend-baselines.

Dat maakt security minder een losse controle achteraf en meer een standaardkwaliteit van je product.

Directe maatregelen (15 minuten)

Waarom dit telt

De kern van Security Headers is risicoreductie in de praktijk. Technische context ondersteunt de maatregelkeuze, maar implementatie en borging staan centraal.

Essentiële headers

Strict-Transport-Security (HSTS)

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

Vertelt de browser: “Gebruik alleen HTTPS. Altijd. Geen uitzonderingen.” Voorkomt SSL-stripping aanvallen en onbedoeld HTTP-verkeer.

Parameter Betekenis
max-age=31536000 Browser onthoudt dit 1 jaar
includeSubDomains Geldt ook voor alle subdomeinen
preload Toestaan voor opname in browser preload-lijst

Let op: HSTS preload is een eenrichtingsstraat. Zodra je domein in de preload-lijst staat, is het verwijderen een proces van maanden. Test eerst grondig met een korte max-age (bijv. 300 seconden).

X-Content-Type-Options

X-Content-Type-Options: nosniff

Voorkomt MIME-sniffing: de browser vertrouwt het Content-Type header en raadt niet zelf wat een bestand is. Zonder deze header kan een geüpload bestand dat eruitziet als een afbeelding maar JavaScript bevat, als script worden uitgevoerd.

X-Frame-Options

X-Frame-Options: DENY

Voorkomt dat de pagina in een <iframe> kan worden geladen. Beschermt tegen clickjacking. Gebruik SAMEORIGIN als je eigen site iframes nodig heeft.

Opmerking: X-Frame-Options wordt geleidelijk vervangen door CSP frame-ancestors. Stel beide in voor backward compatibility.

Referrer-Policy

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

Beperkt welke informatie de browser meestuurt in de Referer header bij navigatie. Voorkomt dat interne URL-structuren, zoekqueries of tokens lekken naar externe sites.

Waarde Effect
no-referrer Nooit een referer meesturen
same-origin Alleen bij navigatie binnen eigen domein
strict-origin Alleen het origin (geen pad) bij cross-origin
strict-origin-when-cross-origin Volledig pad bij same-origin, alleen origin bij cross-origin

Permissions-Policy

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

Blokkeert toegang tot gevoelige browser-API’s. Voorkomt dat een XSS-payload de webcam, microfoon of locatie activeert.

Veelgebruikte features om te blokkeren:

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

Deze header activeerde het XSS-filter in oudere browsers. Het filter is verwijderd uit alle moderne browsers omdat het zelf kwetsbaarheden introduceerde (XSS Auditor bypass). Stel deze header in op 0 om te voorkomen dat oudere browsers het kapotte filter activeren. Vertrouw in plaats daarvan op CSP.

Content-Security-Policy in detail

CSP is de krachtigste security header. Hij definieert welke bronnen de browser mag laden voor elk type content. Een goed geconfigureerde CSP blokkeert XSS, data-exfiltratie en clickjacking in één keer.

Basisstructuur

Content-Security-Policy: directief-1 waarde1 waarde2; directief-2 waarde3;

Belangrijke directieven

Directief Controleert Aanbevolen waarde
default-src Fallback voor alle typen 'self'
script-src JavaScript laden/uitvoeren 'self' (geen 'unsafe-inline')
style-src CSS laden 'self' (liefst geen 'unsafe-inline')
img-src Afbeeldingen 'self' data:
connect-src XHR, fetch, WebSocket 'self'
font-src Webfonts 'self'
frame-src Inhoud in iframes 'none' of specifieke origins
frame-ancestors Wie mag deze pagina inframen 'none'
base-uri <base> tag beperking 'self'
form-action Formulier-actie URLs 'self'
object-src Flash, Java applets 'none'
media-src Audio, video 'self'
worker-src Web Workers, Service Workers 'self'
report-uri Rapporteer violations URL naar rapport-endpoint
report-to Reporting API (modern) Groepsnaam

Nonce-based CSP

De veiligste aanpak voor inline scripts: gebruik een uniek, per-request nonce.

Content-Security-Policy: script-src 'nonce-abc123xyz' 'strict-dynamic';
<!-- Dit script mag uitvoeren (nonce matcht) -->
<script nonce="abc123xyz">
  document.getElementById('menu').classList.toggle('open');
</script>

<!-- Dit script wordt geblokkeerd (geen nonce) -->
<script>alert('XSS')</script>

Implementatie 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 de template:

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

strict-dynamic

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

strict-dynamic zorgt ervoor dat scripts die geladen worden door een vertrouwd (nonce-voorzien) script automatisch ook vertrouwd zijn. Dit maakt het mogelijk om third-party libraries dynamisch te laden zonder elke URL in de CSP te zetten.

report-uri en report-to

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

Gebruik Content-Security-Policy-Report-Only om CSP te testen zonder content te blokkeren. De browser rapporteert violations zonder ze af te dwingen.

Voorbeeld 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-migratiepad

  1. Start met Content-Security-Policy-Report-Only en monitor violations
  2. Fix alle violations (verplaats inline scripts naar bestanden, voeg nonces toe)
  3. Schakel over naar Content-Security-Policy (enforcement)
  4. Houd report-uri/report-to actief voor continue monitoring

De drie essentiële flags

Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=Lax; Path=/
Flag Beschermt tegen Effect
Secure Network sniffing Cookie wordt alleen via HTTPS verstuurd
HttpOnly XSS-cookie-diefstal JavaScript kan het cookie niet lezen
SameSite=Lax CSRF Cookie wordt niet meegestuurd bij cross-site POST

SameSite in detail

Waarde GET cross-site POST cross-site Gebruik
Strict Niet meesturen Niet meesturen Bankieren, admin-panels
Lax Wel meesturen Niet meesturen Algemene sessiecookies
None Wel meesturen Wel meesturen Alleen met Secure; voor embeds/widgets

Let op: SameSite=None vereist de Secure flag. Zonder Secure wordt SameSite=None genegeerd door de browser.

Cookie-prefixes dwingen extra restricties af op transport-niveau:

Set-Cookie: __Host-session=abc123; Secure; HttpOnly; SameSite=Lax; Path=/
Set-Cookie: __Secure-token=xyz789; Secure; HttpOnly; SameSite=Strict
Prefix Vereisten
__Host- Secure flag, geen Domain attribuut, Path=/ — cookie is gebonden aan exact dit domein
__Secure- Secure flag — cookie gaat alleen over HTTPS

__Host- is de sterkste optie: het voorkomt dat een aanvaller op een subdomein het cookie kan overschrijven.

Domain en Path

Set-Cookie: session=abc; Domain=example.com; Path=/app
  • Zonder Domain: Cookie geldt alleen voor het exacte domein (niet subdomeinen)
  • Met Domain=example.com: Cookie geldt ook voor alle subdomeinen — risicovoller
  • Path=/app: Cookie wordt alleen meegestuurd voor requests naar /app/*

Best practice: Laat Domain weg (of gebruik __Host- prefix) tenzij je expliciet subdomeinen nodig hebt.

CORS-headers

Cross-Origin Resource Sharing bepaalt welke externe origins je API mogen aanroepen. Verkeerde CORS-configuratie is een van de meest voorkomende bevindingen bij webapplicatie-assessments.

De headers

Header Doel
Access-Control-Allow-Origin Welke origin mag de response lezen
Access-Control-Allow-Credentials Mag de browser cookies/auth meesturen
Access-Control-Allow-Methods Toegestane HTTP-methoden (preflight)
Access-Control-Allow-Headers Toegestane request headers (preflight)
Access-Control-Expose-Headers Welke response headers de browser mag lezen
Access-Control-Max-Age Hoe lang preflight-resultaat gecacht wordt

Veilige configuratie

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

Veelgemaakte fouten

1. Origin reflecteren (de papegaai)

# FOUT — reflecteert elke 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

Dit is functioneel gelijk aan geen CORS-beveiliging: elke site kan je API aanroepen met cookies van de gebruiker.

2. Wildcard met credentials

# FOUT — browsers blokkeren dit, maar de intentie toont een misverstand
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

De browser weigert dit, maar het feit dat je het probeert betekent dat je CORS niet begrijpt.

3. Null origin toestaan

# FOUT — 'null' origin komt van sandboxed iframes en data: URLs
Access-Control-Allow-Origin: null

Een aanvaller kan een sandboxed iframe gebruiken om requests te sturen met Origin: null.

4. Regex zonder anchoring

# FOUT — matcht ook evil-example.com en example.com.evil.com
if re.search(r'example\.com', origin):
    allow(origin)

Gebruik altijd een exacte whitelist of anchor je regex:

# GOED — exacte 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

Implementatie per webserver

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;

Gebruik in je server-blok:

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

    include snippets/security-headers.conf;

    # ... rest van de configuratie
}

Let op: het keyword always zorgt ervoor dat headers ook op foutpagina’s (4xx, 5xx) worden gezet. Zonder always ontbreken headers op error-responses — precies het moment dat je ze het hardst nodig hebt.

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';"

Activeer met:

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

Implementatie 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 — ingebouwde 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,
  },
}));

Testtools

Geautomatiseerde scanners

Tool URL / Commando Wat het test
SecurityHeaders.com https://securityheaders.com/?q=example.com Alle security headers, grading A-F
Mozilla Observatory https://observatory.mozilla.org Headers + TLS + best practices
CSP Evaluator https://csp-evaluator.withgoogle.com CSP-beleid op zwakke plekken

Handmatig testen

# Alle response headers bekijken
curl -I https://example.com

# Specifiek een header checken
curl -sI https://example.com | grep -i content-security-policy

# CSP testen met een report-only header
curl -sI https://example.com | grep -i content-security-policy-report

Browser DevTools

  1. Open DevTools → Network tab
  2. Klik op een request → bekijk Response Headers
  3. Open ConsoleCSP violations verschijnen als errors
  4. Application tab → Cookies → check flags per cookie

Checklist

Header Waarde Prioriteit
Strict-Transport-Security max-age=31536000; includeSubDomains Kritiek
X-Content-Type-Options nosniff Kritiek
X-Frame-Options DENY Hoog
Content-Security-Policy Restrictief, geen unsafe-inline Kritiek
Referrer-Policy strict-origin-when-cross-origin Hoog
Permissions-Policy Camera, mic, geo uitschakelen Medium
X-XSS-Protection 0 Medium
Cookie: Secure Op alle cookies Kritiek
Cookie: HttpOnly Op sessiecookies Kritiek
Cookie: SameSite Lax of Strict Hoog
Cookie: __Host- prefix Op sessiecookies Aanbevolen
CORS Expliciete whitelist, nooit reflected origin Hoog

Het mooie aan security headers is dat ze bijna alles gratis oplossen. Clickjacking? Eén header. MIME-sniffing? Eén header. XSS? Eén (grotere) header. Cross-origin data-diefstal? Twee headers. Cookie-hijacking? Drie flags. Alles bij elkaar misschien vijftien regels configuratie.

Maar we doen het niet. In plaats daarvan bouwen we een Web Application Firewall met drieduizend regels, zetten er een reverse proxy voor met nog eens vijfhonderd regels, en huren een Security Operations Center in dat 24/7 meekijkt of iemand <script> typt in een zoekveld. Dat kost honderdduizend euro per jaar. De headers kosten nul euro en vijf minuten.

En als we dan gevraagd worden “Waarom staan er geen security headers op jullie applicatie?”, dan is het antwoord: “Dat stond op de backlog.” Achter “de knop een halve pixel naar links verplaatsen” en “de easter egg voor Pasen updaten.” Want prioriteiten.

Samenvatting

Security headers zijn de eenvoudigste en goedkoopste verdedigingslaag die je kunt implementeren. CSP beschermt tegen XSS en data-exfiltratie. HSTS dwingt HTTPS af. Cookie-flags voorkomen sessiediefstal. CORS voorkomt ongeautoriseerde API-toegang.

Het kost vijf minuten om ze in te stellen. Het kost maanden om de schade te herstellen als je het niet doet.

In het volgende hoofdstuk kijken we naar wat er gebeurt voordat die headers er zijn: hoe valideer je invoer en codeer je uitvoer om kwetsbaarheden bij de bron te voorkomen?

Op de hoogte blijven?

Ontvang maandelijks cybersecurity-inzichten in je inbox.

← Webbeveiliging ← Home