In the previous tutorial, you learned how to manage secrets safely. In this article, you will learn about HTTP security headers — simple response headers that tell browsers how to protect your users. Adding the right headers takes minutes and prevents entire categories of attacks.
Why Security Headers Matter
Security headers are instructions from your server to the browser. They say things like:
- “Only load scripts from my domain” (CSP)
- “Always use HTTPS” (HSTS)
- “Do not allow this page to be embedded in an iframe” (X-Frame-Options)
Without these headers, browsers use permissive defaults that leave your users vulnerable. Adding headers is one of the highest-impact, lowest-effort security improvements you can make.
You can test your site’s headers at securityheaders.com. Most sites score an F.
Content-Security-Policy (CSP)
CSP is the most powerful security header. It tells the browser exactly which resources are allowed to load on your page. If an attacker injects a malicious script (XSS), CSP blocks it because the script’s source is not in the allowlist.
How CSP Works
Without CSP, the browser loads any script, style, image, or font — including malicious ones injected through XSS.
With CSP, you define an allowlist. Anything not in the list is blocked.
Basic CSP Example
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' https://images.example.com
This policy means:
- default-src ‘self’ — by default, only load resources from the same origin
- script-src ‘self’ — only run scripts from the same origin (blocks inline scripts and external scripts)
- style-src ‘self’ — only load stylesheets from the same origin
- img-src ‘self’ https://images.example.com — load images from the same origin OR from images.example.com
CSP Directives
| Directive | Controls | Example |
|---|---|---|
default-src | Fallback for all resource types | 'self' |
script-src | JavaScript sources | 'self' https://cdn.example.com |
style-src | CSS sources | 'self' 'unsafe-inline' |
img-src | Image sources | 'self' data: https: |
font-src | Font sources | 'self' https://fonts.googleapis.com |
connect-src | Fetch, XHR, WebSocket destinations | 'self' https://api.example.com |
frame-src | iframe sources | 'none' |
object-src | Plugin content (Flash, Java) | 'none' |
base-uri | Allowed <base> tag URLs | 'self' |
form-action | Allowed form submission targets | 'self' |
frame-ancestors | Who can embed this page (replaces X-Frame-Options) | 'none' |
CSP for a Typical Web Application
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://api.example.com; frame-ancestors 'none'; object-src 'none'; base-uri 'self'
The Problem With unsafe-inline
Many CSP configurations include 'unsafe-inline' for styles or scripts. This weakens CSP significantly because it allows inline scripts — which is exactly what XSS injects.
For scripts: Use nonces or hashes instead of 'unsafe-inline':
Content-Security-Policy: script-src 'self' 'nonce-abc123random'
<!-- Only scripts with the matching nonce will execute -->
<script nonce="abc123random">
console.log("This is allowed");
</script>
<!-- This injected script will be BLOCKED — no nonce -->
<script>alert("XSS attack!")</script>
The nonce must be random and unique per request. Your server generates it and includes it in both the CSP header and the script tags.
CSP Report-Only Mode
Before enforcing CSP, test it in report-only mode. This mode logs violations without blocking anything:
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report
The browser sends violation reports to your endpoint. Fix your code to comply with the policy, then switch to enforcement.
Note: The report-uri directive is deprecated in newer browsers. The modern replacement is report-to with a Reporting-Endpoints header. For now, including both provides the widest browser support.
Strict-Transport-Security (HSTS)
HSTS tells the browser: “Always use HTTPS for this domain. Never use HTTP.”
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
- max-age=31536000 — remember this rule for 1 year (in seconds)
- includeSubDomains — apply to all subdomains too
- preload — submit to browser’s built-in HSTS preload list
Why HSTS Matters
Without HSTS, an attacker on the same network can perform an SSL stripping attack:
- User types
example.comin the browser - Browser sends an HTTP request to
http://example.com - Attacker intercepts this request (man-in-the-middle)
- Attacker proxies the connection — HTTPS to the server, HTTP to the user
- User sees a working page but everything is unencrypted to the attacker
With HSTS, the browser never makes the HTTP request. It goes directly to HTTPS.
HSTS Preload List
Even HSTS has a first-visit problem — the very first request might be HTTP. The HSTS preload list solves this. If your domain is on the list, browsers use HTTPS from the very first visit — before ever contacting your server.
Submit your domain at hstspreload.org.
X-Frame-Options
This header prevents your page from being embedded in an iframe. This stops clickjacking — an attack where an attacker overlays your page with invisible iframes to trick users into clicking hidden buttons.
X-Frame-Options: DENY
| Value | Meaning |
|---|---|
DENY | Page cannot be displayed in any iframe |
SAMEORIGIN | Page can only be iframed by the same origin |
Note: CSP’s frame-ancestors directive is the modern replacement for X-Frame-Options and is more flexible. Use both for backward compatibility:
X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'
X-Content-Type-Options
Browsers sometimes “guess” the content type of a response (MIME sniffing). An attacker can upload a .jpg file that contains HTML/JavaScript. Without this header, the browser might execute it.
X-Content-Type-Options: nosniff
This tells the browser: “Trust the Content-Type header. Do not guess.”
Always set this header. There is no reason not to.
Referrer-Policy
When a user clicks a link from your site to another site, the browser sends a Referer header with the URL they came from. This can leak sensitive information (search queries, user IDs in URLs, internal page paths).
Referrer-Policy: strict-origin-when-cross-origin
| Value | Behavior |
|---|---|
no-referrer | Never send referrer information |
same-origin | Only send referrer for same-origin requests |
strict-origin-when-cross-origin | Send full URL for same-origin, only origin for cross-origin, nothing for HTTP downgrade |
origin | Only send the origin (domain), not the full URL |
strict-origin-when-cross-origin is a good default. It sends full referrer information within your site (useful for analytics) but only the domain when navigating to other sites.
Permissions-Policy
This header controls which browser features your page can use — camera, microphone, geolocation, payment APIs, and more. If your site does not use the camera, disable it. This prevents malicious scripts from accessing it.
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
The empty parentheses () mean “disabled for all origins.” You can allow specific origins:
Permissions-Policy: camera=(self "https://video.example.com"), microphone=()
Setting All Headers at Once
Go
func securityHeadersMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Prevent XSS with CSP
w.Header().Set("Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; frame-ancestors 'none'; object-src 'none'")
// Force HTTPS
w.Header().Set("Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload")
// Prevent clickjacking
w.Header().Set("X-Frame-Options", "DENY")
// Prevent MIME sniffing
w.Header().Set("X-Content-Type-Options", "nosniff")
// Control referrer information
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Disable unnecessary browser features
w.Header().Set("Permissions-Policy",
"camera=(), microphone=(), geolocation=(), payment=()")
next.ServeHTTP(w, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", homeHandler)
// Wrap all routes with security headers
// Note: serve over HTTPS (ListenAndServeTLS) in production —
// the HSTS header is ignored by browsers over plain HTTP.
http.ListenAndServe(":8080", securityHeadersMiddleware(mux))
}
Python (Django)
# settings.py
# HSTS
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
# HTTPS
SECURE_SSL_REDIRECT = True
# X-Frame-Options
X_FRAME_OPTIONS = "DENY"
# X-Content-Type-Options
SECURE_CONTENT_TYPE_NOSNIFF = True
# Referrer-Policy
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
# CSP — use django-csp package
# pip install django-csp
MIDDLEWARE = [
"csp.middleware.CSPMiddleware",
# ...
]
CSP_DEFAULT_SRC = ("'self'",)
CSP_SCRIPT_SRC = ("'self'",)
CSP_STYLE_SRC = ("'self'", "'unsafe-inline'")
CSP_IMG_SRC = ("'self'", "data:", "https:")
CSP_FRAME_ANCESTORS = ("'none'",)
CSP_OBJECT_SRC = ("'none'",)
Python (Flask)
# Install: pip install flask-talisman
from flask import Flask
from flask_talisman import Talisman
app = Flask(__name__)
# Talisman sets all security headers at once
csp = {
"default-src": "'self'",
"script-src": "'self'",
"style-src": "'self' 'unsafe-inline'",
"img-src": "'self' data: https:",
"frame-ancestors": "'none'",
"object-src": "'none'",
}
Talisman(
app,
content_security_policy=csp,
strict_transport_security=True,
strict_transport_security_max_age=31536000,
strict_transport_security_include_subdomains=True,
frame_options="DENY",
content_type_nosniff=True,
referrer_policy="strict-origin-when-cross-origin",
permissions_policy={
"camera": "()",
"microphone": "()",
"geolocation": "()",
},
)
JavaScript (Express)
import express from "express";
import helmet from "helmet";
const app = express();
// Helmet sets all security headers at once
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
frameAncestors: ["'none'"],
objectSrc: ["'none'"],
},
},
strictTransportSecurity: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
frameguard: { action: "deny" },
noSniff: true,
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
permittedCrossDomainPolicies: false,
permissionsPolicy: {
features: {
camera: ["'none'"],
microphone: ["'none'"],
geolocation: ["'none'"],
payment: ["'none'"],
},
},
})
);
app.get("/", (req, res) => {
res.send("Hello, secure world!");
});
app.listen(3000);
Helmet is the standard security header library for Express. It sets sensible defaults with one line: app.use(helmet()).
Nginx Configuration
If you use Nginx as a reverse proxy, you can set security headers at the server level:
server {
listen 443 ssl http2;
server_name example.com;
# Security headers
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; frame-ancestors 'none'; object-src 'none'" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
location / {
proxy_pass http://localhost:8080;
}
}
The always keyword ensures headers are sent even for error responses (4xx, 5xx).
Testing Your Headers
securityheaders.com
Visit securityheaders.com and enter your URL. It scans your response headers and gives you a grade from A+ to F. Aim for A or A+.
Browser DevTools
Open DevTools (F12) > Network tab > click any request > Headers tab. Check the response headers.
curl
# Check all response headers
curl -I https://example.com
# Check a specific header
curl -s -I https://example.com | grep -i "content-security-policy"
Mozilla Observatory
observatory.mozilla.org provides a more thorough scan than securityheaders.com, including TLS configuration, cookies, and redirection.
Common Mistakes
- Setting CSP too strict on day one — start with report-only mode, fix violations, then enforce
- Forgetting
'unsafe-inline'for legacy CSS — many sites use inline styles. Add it tostyle-srcbut avoid it forscript-src - Not using the
alwayskeyword in Nginx — without it, error pages do not get security headers - Setting HSTS before HTTPS works everywhere — if any page or subdomain is not HTTPS, users will be locked out
- Copy-pasting CSP from another site — your CSP must match YOUR resources. Every site is different.
The Minimum Security Headers
If you only have time to add a few headers, start with these:
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy: default-src 'self'; frame-ancestors 'none'
This takes 5 lines of configuration and blocks most common attacks.
Prevention Checklist
| Header | Priority | What It Prevents |
|---|---|---|
| Content-Security-Policy | High | XSS, data injection, clickjacking |
| Strict-Transport-Security | High | SSL stripping, downgrade attacks |
| X-Content-Type-Options | High | MIME type confusion attacks |
| X-Frame-Options | High | Clickjacking |
| Referrer-Policy | Medium | Information leakage via referrer |
| Permissions-Policy | Medium | Unauthorized use of browser features |
| CSP Report-Only (during testing) | Medium | Find violations before enforcing CSP |
What is Next?
This is the final article in Part 2 of the Security for Developers series. You now have a solid foundation in web security — from OWASP fundamentals to authentication, CSRF, CORS, API security, secrets management, and security headers.
Check the Security for Developers landing page for upcoming articles on broken access control, Docker security, and more advanced topics.