You need Python 3 and a text editor — nothing else, and no
internet. Every script below is a few lines you run locally. Check
with python3 --version in a terminal (on Windows,
py --version); any 3.x is fine. If it prints nothing useful, do
Introduction to
Python first — about fifteen minutes.
🔴 Everything here runs on your own machine, against files you create. The validation ideas apply to any language and any web framework; Python is just the shortest way to see them work.
What Are Security Headers?
HTTP security headers are directives sent by a web server in its response headers that instruct the browser to enable or disable specific security features. They act as an additional layer of defense, telling the browser how to behave when handling your site's content.
Think of them as security policies your server communicates to every visitor's browser. Without these headers, browsers use their default behavior, which is often permissive. By setting security headers, you restrict what the browser is allowed to do, significantly reducing the attack surface of your web application.
Security headers do not replace secure coding practices -- they complement them. Even if your application has a vulnerability, properly configured headers can prevent or limit exploitation. This is the principle of defense in depth: multiple overlapping protections so that no single failure is catastrophic.
Security headers are configured on the web server (Nginx, Apache, IIS) or in your application code. They cost nothing to implement, require no client-side changes, and can dramatically improve your site's security posture.
Content-Security-Policy (CSP)
Content-Security-Policy is the most powerful security header available. It controls which resources the browser is allowed to load for your page -- scripts, stylesheets, images, fonts, frames, and more. CSP is the primary defense against Cross-Site Scripting (XSS) attacks.
How CSP Works
CSP uses directives to define allowed sources for each resource type. If a resource does not match the policy, the browser blocks it and logs a violation.
# Basic CSP header
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline'; img-src *; frame-src 'none';
Key Directives
'self' to only allow resources from your own domain.
'unsafe-inline' and 'unsafe-eval' -- they defeat the purpose of CSP. Use nonces or hashes instead.
'unsafe-inline' is sometimes needed for legacy sites but should be avoided if possible.
'self' plus specific CDN domains.
'none' if you do not use iframes.
A misconfigured CSP can break your site by blocking legitimate resources. Use
Content-Security-Policy-Report-Only first to monitor violations
without enforcing the policy. Once you are confident no legitimate resources are
blocked, switch to the enforcing header.
Nginx Configuration Example
# In your Nginx server block
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
X-Content-Type-Options
This header prevents browsers from MIME-type sniffing. Without it, a browser might
interpret a file differently than the server intended. For example, an attacker could
upload a file with a .jpg extension that actually contains JavaScript.
If the browser sniffs the content and determines it is JavaScript, it might execute it.
X-Content-Type-Options: nosniff
The nosniff value tells the browser to strictly follow the
Content-Type header sent by the server. If the server says a file is an
image, the browser treats it as an image -- period. It will not try to guess the type
from the content.
This is a single-value header with no configuration complexity. There is no reason not to include it. It prevents an entire class of attacks with zero risk of breaking your site.
X-Frame-Options
X-Frame-Options controls whether your page can be embedded inside an
<iframe> on another site. This is critical for preventing
clickjacking attacks, where an attacker overlays your page with transparent
elements to trick users into clicking hidden buttons.
# Prevent any site from framing your page
X-Frame-Options: DENY
# Only allow your own site to frame your pages
X-Frame-Options: SAMEORIGIN
Clickjacking Example
Imagine a banking site that does not set X-Frame-Options. An attacker creates a page with an invisible iframe loading the bank's transfer page, positioned so the "Confirm Transfer" button aligns with a visible "Click here to win a prize" button. The user thinks they are clicking the prize button, but they are actually confirming a bank transfer.
With X-Frame-Options: DENY, the browser refuses to load the banking
page inside the iframe, and the attack fails completely.
The CSP directive frame-ancestors provides the same protection with
more flexibility (you can whitelist specific domains). Modern browsers support both,
but setting both ensures backward compatibility with older browsers.
Strict-Transport-Security (HSTS)
HSTS forces browsers to only communicate with your site over HTTPS. Once a browser receives an HSTS header, it will automatically convert any HTTP request to HTTPS for the specified duration, without ever making an insecure request.
# Enforce HTTPS for 1 year, including all subdomains
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Parameters
Once a browser caches your HSTS policy, it will refuse plain HTTP connections
until max-age expires. If you later need to serve HTTP for any reason,
visitors who received the header will be unable to connect. Start with a short
max-age and increase it only after confirming everything works.
Referrer-Policy
When a user clicks a link on your site to go to another site, the browser normally
sends a Referer header containing the URL they came from. This can leak
sensitive information such as query parameters, session tokens in URLs, or private
page paths.
# Recommended: send origin only when navigating to another site
Referrer-Policy: strict-origin-when-cross-origin
Common Values
Permissions-Policy
Permissions-Policy (formerly Feature-Policy) controls which browser features and APIs your page can use. This includes sensitive capabilities like the camera, microphone, geolocation, payment requests, and more.
# Disable sensitive features not needed by your site
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()
Each feature is set to a list of allowed origins. An empty list () disables
the feature entirely. (self) allows only your own origin to use it.
Specific domains can be listed for features you need from third-party embeds.
# Allow camera only for your domain and a specific video service
Permissions-Policy: camera=(self "https://video.example.com"), microphone=(), geolocation=()
Disable every feature you do not actively use. If your site has no reason to access the camera, microphone, or GPS, disable them explicitly. This prevents any injected script from accessing these APIs even if an XSS vulnerability exists.
Checking Your Headers
After configuring security headers, you need to verify they are being sent correctly. Here are several methods to check.
Using Browser Developer Tools
Open your browser's Developer Tools (F12), go to the Network tab, click on the initial page request, and look at the Response Headers section. All security headers you configured should appear there.
Using curl
# View all response headers
curl -I https://yoursite.com
# Check for specific headers
curl -sI https://yoursite.com | grep -iE "content-security|x-frame|x-content-type|strict-transport|referrer-policy|permissions-policy"
Online Scanners
Several free tools grade your security headers and provide recommendations:
- securityheaders.com -- Scans your site and provides an A-F grade with detailed explanations for each missing or misconfigured header
- Mozilla Observatory (observatory.mozilla.org) -- Comprehensive scan that checks headers plus additional security best practices
- CSP Evaluator (csp-evaluator.withgoogle.com) -- Specifically analyzes your Content-Security-Policy for weaknesses
Complete Nginx Example
# Add to your Nginx server block or include file
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
always keyword matters.
In Nginx, add_header without always only sends the
header on successful responses (2xx/3xx). Error pages (4xx/5xx) will not include
your security headers. Always use the always parameter to ensure
headers are sent on every response.
Now Do It Yourself: Five Steps
Security headers are a server telling the browser “here are the rules for this page”. You will build a small auditor that finds the missing ones, watch it go quiet once they are all present, model exactly what a Content-Security-Policy blocks, see why clickjacking has two valid defences, and trace how one header silently upgrades every future visit to HTTPS. Every output below was captured by running the code on Python 3.12; the checks are the same ones an online header scanner runs.
Go: open a terminal, make a folder with mkdir headers-lab and cd headers-lab, and open your text editor.
Do: save these nineteen lines as check.py, then run python3 check.py.
REQUIRED = {
"Content-Security-Policy": "controls what the page may load and run (blocks most XSS)",
"X-Content-Type-Options": "stops the browser guessing a file's type (MIME sniffing)",
"X-Frame-Options": "stops your page being framed by another site (clickjacking)",
"Strict-Transport-Security": "forces HTTPS for future visits",
"Referrer-Policy": "limits what URL you leak when a visitor clicks away",
}
def audit(response_headers):
have = {k.lower() for k in response_headers}
return [h for h in REQUIRED if h.lower() not in have]
typical_site = {
"Content-Type": "text/html",
"Server": "nginx",
"X-Content-Type-Options": "nosniff",
}
missing = audit(typical_site)
print(f"{len(missing)} protective header(s) missing:")
for h in missing:
print(f" - {h}: {REQUIRED[h]}")
You should see: 4 protective header(s) missing:, then the four the site did not send, each with what it would have done. The one it did send — X-Content-Type-Options — is correctly not listed. Note the lower-casing: HTTP header names are case-insensitive, so a real auditor must compare them case-folded or it will miss content-security-policy written in a different case. Four missing headers is a completely ordinary starting point for a site nobody has hardened.
If not: if it reports all five missing, your typical_site dictionary lost its X-Content-Type-Options entry. If it reports zero, you accidentally listed the required headers as already present — the point of the exercise is to start from a site that is missing them.
Go: same file.
Do: save this complete file as check2.py — it is check.py with every header now present — and run python3 check2.py.
REQUIRED = {
"Content-Security-Policy": "controls what the page may load and run (blocks most XSS)",
"X-Content-Type-Options": "stops the browser guessing a file's type (MIME sniffing)",
"X-Frame-Options": "stops your page being framed by another site (clickjacking)",
"Strict-Transport-Security": "forces HTTPS for future visits",
"Referrer-Policy": "limits what URL you leak when a visitor clicks away",
}
def audit(response_headers):
have = {k.lower() for k in response_headers}
return [h for h in REQUIRED if h.lower() not in have]
typical_site = {
"Content-Type": "text/html",
"Content-Security-Policy": "default-src 'self'",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
"Referrer-Policy": "strict-origin-when-cross-origin",
}
missing = audit(typical_site)
print(f"{len(missing)} protective header(s) missing:")
for h in missing:
print(f" - {h}: {REQUIRED[h]}")
You should see: 0 protective header(s) missing: and no list beneath it. That is the entire job of hardening headers: they are declarative, you set each one once in your server config, and every response carries them. Notice they cost nothing at runtime and break nothing for honest visitors — which is why a missing security header is one of the easiest findings to close on any audit, and one of the most common to find open.
If not: if one header is still reported missing, its key does not match the name in REQUIRED — a typo like X-Frame-Option (no s) is exactly the kind of silent mistake the auditor exists to catch. Fix the spelling and it goes quiet.
Go: same folder, new file.
Do: save these sixteen lines as csp.py, then run it.
def blocks(csp, resource_origin, kind):
directive = {"script": "script-src", "img": "img-src"}[kind]
for part in csp.split(";"):
part = part.strip()
if part.startswith(directive):
allowed = part.split()[1:]
ok = ("'self'" in allowed and resource_origin == "self") or resource_origin in allowed
return not ok
return False
policy = "default-src 'self'; script-src 'self'; img-src 'self' https://cdn.example"
print("inline script ->", "BLOCKED" if blocks(policy, "inline", "script") else "allowed")
print("script from self ->", "BLOCKED" if blocks(policy, "self", "script") else "allowed")
print("script from evil.com ->", "BLOCKED" if blocks(policy, "https://evil.example", "script") else "allowed")
print("img from cdn.example ->", "BLOCKED" if blocks(policy, "https://cdn.example", "img") else "allowed")
You should see: BLOCKED, allowed, BLOCKED, allowed. Read them against the policy. script-src 'self' permits scripts from your own origin and refuses everything else — including inline scripts and anything from evil.example, which is why a good CSP is the strongest single defence against XSS: even if an attacker injects a <script>, the browser will not run it. Images have their own img-src that additionally allows cdn.example, showing that CSP is per-resource-type, not one global switch.
If not: if the inline script is allowed, your policy has 'unsafe-inline' in script-src — the single directive that hands XSS back its power. Real policies avoid it, using nonces or hashes for the rare script that must be inline.
Go: same folder, new file.
Do: save these twelve lines as clickjack.py, then run it.
def frameable(headers):
xfo = headers.get("X-Frame-Options", "").upper()
csp = headers.get("Content-Security-Policy", "")
if xfo in ("DENY", "SAMEORIGIN"):
return False
if "frame-ancestors" in csp:
return False
return True
print("no protection ->", "frameable (clickjacking risk)" if frameable({}) else "safe")
print("X-Frame-Options ->", "frameable" if frameable({"X-Frame-Options": "DENY"}) else "safe")
print("CSP frame-ancestors ->", "frameable" if frameable({"Content-Security-Policy": "frame-ancestors 'none'"}) else "safe")
You should see: frameable (clickjacking risk), then safe, then safe. Clickjacking is an attacker loading your page in an invisible frame over their own, so a victim’s click lands on your button without their knowing. Two different headers stop it: the older X-Frame-Options: DENY and the newer Content-Security-Policy: frame-ancestors. Either is enough, which is why an auditor must accept either — scoring a site as vulnerable because it uses the modern CSP form instead of the legacy header would be a false alarm.
If not: if the first line says safe, your frameable function is returning the wrong default — a page with no anti-framing header is the risky case, so the empty-headers call must come back frameable.
Go: same folder, new file.
Do: save these eight lines as hsts.py, then run it.
def scheme_used(url_typed, hsts_remembered):
if url_typed.startswith("https://"):
return "https"
return "https" if hsts_remembered else "http"
print("first visit, typed http:// ->", scheme_used("http://bank.example", hsts_remembered=False))
print("after HSTS, typed http:// ->", scheme_used("http://bank.example", hsts_remembered=True))
You should see: http then https. The first time a visitor types http://, the browser really does make an insecure request — the window an attacker on the same network needs. But if your site once sent Strict-Transport-Security, the browser remembers, and from then on it rewrites http:// to https:// itself, before a single insecure byte leaves the machine. That is why HSTS matters even on a site that already redirects to HTTPS: the redirect happens after the risky first request, and HSTS removes the request entirely. To close even the first visit, sites submit to the browser-shipped preload list.
If not: both lines the same means the branch is wrong — the whole point is that the only difference is whether HSTS was remembered, and it flips the second line from http to https.
Without scrolling up: in step 3 the CSP blocked an inline
<script> even from your own site. Why is that the strongest
single defence against XSS, and what one directive value would quietly undo it?
Answer: even if an attacker injects a script, script-src 'self'
means the browser refuses to run it, so the injection is inert. Adding
'unsafe-inline' to script-src re-enables inline scripts
and hands XSS its power back.
Now do it without the page: extend the audit
function from step 1 so that a header being present but weak is also
reported — for example, Strict-Transport-Security with a
max-age under 15552000 (six months). You have the shape already:
check membership first, then, when present, inspect the value.
Summary
HTTP security headers are a low-cost, high-impact defense layer for any web application. Here is what you learned:
- Content-Security-Policy -- Controls which resources the browser can load, preventing XSS and data injection attacks
- X-Content-Type-Options -- Prevents MIME-type sniffing with a simple
nosniffdirective - X-Frame-Options -- Blocks clickjacking by preventing your pages from being embedded in iframes
- Strict-Transport-Security -- Forces HTTPS connections, eliminating SSL stripping attacks
- Referrer-Policy -- Controls how much URL information leaks when users navigate to other sites
- Permissions-Policy -- Restricts which browser APIs (camera, mic, GPS) your page can access
- Always verify your headers using browser tools,
curl, or online scanners
You now know how to harden web applications using HTTP security headers. These headers work alongside secure coding practices to create multiple layers of protection. Next, learn about Cross-Site Scripting (XSS) to understand one of the attacks these headers help prevent.