Skip to content

HTTP Security Headers

💡
Before you start

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.

💡
Defense in Depth

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

default-src Fallback policy for any resource type not explicitly defined. Start with 'self' to only allow resources from your own domain.
script-src Controls where JavaScript can be loaded from. Avoid 'unsafe-inline' and 'unsafe-eval' -- they defeat the purpose of CSP. Use nonces or hashes instead.
style-src Controls where CSS can be loaded from. 'unsafe-inline' is sometimes needed for legacy sites but should be avoided if possible.
img-src Controls where images can be loaded from. Use 'self' plus specific CDN domains.
frame-src Controls which domains can be embedded in iframes. Set to 'none' if you do not use iframes.
connect-src Controls which URLs the page can connect to via fetch, XMLHttpRequest, WebSocket, and EventSource.
⚠️
Start with Report-Only

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.

💡
Always set this header.

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.

💡
CSP frame-ancestors vs. X-Frame-Options

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

max-age How long (in seconds) the browser should remember to only use HTTPS. 31536000 = 1 year. Start with a shorter value (e.g., 86400 = 1 day) while testing.
includeSubDomains Applies the HTTPS-only rule to all subdomains. Only use this if ALL your subdomains support HTTPS.
preload Signals that you want to be included in browsers' built-in HSTS preload list. Submit at hstspreload.org after thorough testing.
⚠️
HSTS is difficult to undo.

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

no-referrer Never send the Referer header. Maximum privacy but breaks analytics and some anti-CSRF protections.
same-origin Send the full URL only for same-origin requests. No referrer sent to external sites.
strict-origin-when-cross-origin Send the full URL for same-origin requests. For cross-origin requests, send only the origin (domain). Send nothing if downgrading from HTTPS to HTTP. This is the recommended default.
no-referrer-when-downgrade Send the full URL unless navigating from HTTPS to HTTP. This is the browser default if no policy is set.

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=()
💡
Principle of Least Privilege

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;
⚠️
The 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.

1
Audit a typical response and find the holes

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.

2
Add the headers and watch the auditor go quiet

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.

3
Model what a Content-Security-Policy actually blocks

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.

4
See why clickjacking has two valid defences

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.

5
Trace how HSTS upgrades every future visit

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.

🎉
Check yourself before moving on

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 nosniff directive
  • 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
🎉
Great work!

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.