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-Optionsis gradually being replaced by CSPframe-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 responseIn the template:
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 '', 204CSP migration path
- Start with
Content-Security-Policy-Report-Onlyand monitor violations - Fix all violations (move inline scripts to files, add nonces)
- Switch to
Content-Security-Policy(enforcement) - Keep
report-uri/report-toactive for continuous monitoring
Cookie flags in detail
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=Nonerequires theSecureflag. WithoutSecure,SameSite=Noneis ignored by the browser.
Cookie prefixes
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 responseThis 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 responseImplementation 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
alwayskeyword ensures that headers are also set on error pages (4xx, 5xx). Withoutalways, 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:
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-reportBrowser DevTools
- Open DevTools → Network tab
- Click on a request → view Response Headers
- Open Console → CSP violations appear as errors
- 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?
Further reading in the knowledge base
These articles in the portal provide more background and practical context:
- APIs — the invisible glue of the internet
- SSL/TLS — why that lock icon in your browser matters
- Encryption — the art of making things unreadable
- Password hashing — how websites store your password
- Penetration tests vs. vulnerability scans
You need an account to access the knowledge base. Log in or register.
Related security measures
These articles provide additional context and depth: