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-Optionswordt geleidelijk vervangen door CSPframe-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 responseIn de template:
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 '', 204CSP-migratiepad
- Start met
Content-Security-Policy-Report-Onlyen monitor violations - Fix alle violations (verplaats inline scripts naar bestanden, voeg nonces toe)
- Schakel over naar
Content-Security-Policy(enforcement) - Houd
report-uri/report-toactief voor continue monitoring
Cookie-flags uitgebreid
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=Nonevereist deSecureflag. ZonderSecurewordtSameSite=Nonegenegeerd door de browser.
Cookie-prefixes
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 responseDit 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 responseImplementatie 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
alwayszorgt ervoor dat headers ook op foutpagina’s (4xx, 5xx) worden gezet. Zonderalwaysontbreken 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:
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-reportBrowser DevTools
- Open DevTools → Network tab
- Klik op een request → bekijk Response Headers
- Open Console → CSP violations verschijnen als errors
- 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?
Verder lezen in de kennisbank
Deze artikelen in het portaal geven je meer achtergrond en praktische context:
- API's — de onzichtbare lijm van het internet
- SSL/TLS — waarom dat slotje in je browser ertoe doet
- Encryptie — de kunst van het onleesbaar maken
- Wachtwoord-hashing — hoe websites je wachtwoord opslaan
- Penetratietesten vs. vulnerability scans
Je hebt een account nodig om de kennisbank te openen. Inloggen of registreren.
Gerelateerde securitymaatregelen
Deze artikelen bieden aanvullende context en verdieping: