API & Web Security Headers
HTTP security headers are one of the highest-impact, lowest-effort security measures available. Most are single lines of configuration that defend against entire classes of attacks: cross-site scripting, clickjacking, MIME sniffing, and data leakage. They are set once and rarely changed -- which is why they are so often forgotten. A missing Content-Security-Policy header leaves every user vulnerable to XSS. A missing X-Frame-Options header makes every page clickjackable. These are solved problems, and the solutions take minutes to deploy.
CORS: Cross-Origin Resource Sharing
CORS controls which domains can make requests to your API from a browser. Without CORS headers, browsers enforce the same-origin policy: a page on evil.com cannot make AJAX requests to yourapi.com. CORS relaxes this policy in a controlled way.
The Danger of Wildcard CORS
# NEVER do this for APIs that handle authenticated requests
Access-Control-Allow-Origin: *
A wildcard * means any website can make requests to your API. If your API relies on cookies or session tokens, a malicious site can make authenticated requests on behalf of the user. The browser sends cookies automatically -- the attacker does not need to steal them.
Correct CORS Configuration
# Allow specific origins only
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400
Key rules:
- List specific allowed origins. If you need multiple origins, validate the
Originheader on the server and echo back the matching origin. Access-Control-Allow-Credentials: truecannot be used withAccess-Control-Allow-Origin: *. The browser rejects this combination.Access-Control-Max-Agecaches the preflight response so the browser does not send OPTIONS requests before every request.
# Server-side origin validation (pseudocode)
allowed_origins = ["https://app.example.com", "https://admin.example.com"]
if request.origin in allowed_origins:
response.headers["Access-Control-Allow-Origin"] = request.origin
response.headers["Vary"] = "Origin" # Critical for caching
Always include Vary: Origin when the CORS response varies by origin. Without it, a CDN or browser cache may serve a response with the wrong origin header.
The British Airways Breach (2018)
Attackers injected a malicious script into British Airways' website through a compromised third-party JavaScript library. The script captured payment card details as customers entered them and sent the data to an attacker-controlled server. A strict Content-Security-Policy (covered below) that restricted where scripts could send data would have prevented the exfiltration. Weak CORS and CSP configurations made the attack trivial.
CSP: Content Security Policy
CSP is the most powerful security header. It tells the browser which sources of content are allowed, preventing cross-site scripting (XSS) and data injection attacks.
Basic CSP
Content-Security-Policy:
default-src 'self';
script-src 'self' https://cdn.example.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
CSP Directives
Directive What It Controls
───────────────────────────────────────────────────────────
default-src Fallback for all other directives
script-src JavaScript sources
style-src CSS sources
img-src Image sources
font-src Font sources
connect-src XHR, fetch, WebSocket destinations
frame-src iframe sources
frame-ancestors Who can embed this page in an iframe
media-src Audio and video sources
object-src Plugin content (Flash, Java)
base-uri Allowed values for <base> element
form-action Allowed form submission targets
report-uri Where to send violation reports
report-to Reporting API endpoint (newer)
Deploying CSP Without Breaking Your Site
Start with report-only mode. This logs violations without blocking anything.
# Report-only mode: log but don't block
Content-Security-Policy-Report-Only:
default-src 'self';
report-uri /csp-violations;
Review the violation reports, adjust the policy, and then switch to enforcement mode. This incremental approach prevents breaking your site with an overly restrictive policy.
Avoid unsafe-inline and unsafe-eval
# Bad: allows inline scripts (XSS can execute)
script-src 'self' 'unsafe-inline';
# Bad: allows eval() (code injection can execute)
script-src 'self' 'unsafe-eval';
# Better: use nonces for inline scripts
script-src 'self' 'nonce-abc123def456';
With nonce-based CSP, every inline script must include the nonce attribute. The nonce changes on every page load, so an attacker cannot inject a script with the correct nonce.
<script nonce="abc123def456">
// This script is allowed
</script>
<script>
// This script is blocked (no matching nonce)
</script>
X-Content-Type-Options
X-Content-Type-Options: nosniff
This single header prevents MIME type sniffing. Without it, browsers may interpret a file differently from its declared Content-Type. An attacker could upload a file with a .jpg extension that contains JavaScript, and the browser might execute it.
Always set this header. There is no reason not to.
X-Frame-Options
X-Frame-Options controls whether your page can be embedded in an iframe. This prevents clickjacking attacks, where an attacker overlays a transparent iframe of your site over a malicious page, tricking users into clicking buttons they cannot see.
# Prevent all framing
X-Frame-Options: DENY
# Allow framing only by the same origin
X-Frame-Options: SAMEORIGIN
The CSP frame-ancestors directive is the modern replacement for X-Frame-Options and provides more flexibility. Use both for backward compatibility.
# CSP equivalent (more flexible)
Content-Security-Policy: frame-ancestors 'none';
Content-Security-Policy: frame-ancestors 'self' https://trusted.example.com;
Clickjacking in Practice
In 2015, a clickjacking vulnerability in Adobe Flash's settings page allowed attackers to grant webcam and microphone access by tricking users into clicking invisible buttons. The attack overlaid a game on top of the Flash settings iframe. X-Frame-Options: DENY would have prevented the iframe from loading.
Referrer-Policy
Referrer-Policy controls how much referrer information is included when navigating away from your page or loading external resources. URLs can contain sensitive information (session tokens, search queries, internal paths).
# Recommended: send origin only for cross-origin, full URL for same-origin
Referrer-Policy: strict-origin-when-cross-origin
# More restrictive: never send referrer
Referrer-Policy: no-referrer
Policy What Is Sent Cross-Origin
───────────────────────────────────────────────────────────────
no-referrer Nothing
no-referrer-when-downgrade Full URL (HTTPS→HTTPS), nothing (HTTPS→HTTP)
origin Origin only (https://example.com)
origin-when-cross-origin Origin only cross-origin, full URL same-origin
same-origin Full URL same-origin, nothing cross-origin
strict-origin Origin (HTTPS→HTTPS), nothing (HTTPS→HTTP)
strict-origin-when-cross-origin Best default. Origin cross-origin, full same-origin
unsafe-url Full URL always (never use this)
Permissions-Policy
Permissions-Policy (formerly Feature-Policy) controls which browser features your page can use. It prevents third-party scripts from accessing sensitive APIs like the camera, microphone, or geolocation without your explicit consent.
Permissions-Policy:
camera=(),
microphone=(),
geolocation=(self),
payment=(self "https://payments.example.com"),
usb=(),
interest-cohort=()
Value Meaning
─────────────────────────────────────────────
() Disabled entirely
(self) Allowed for same-origin only
(*) Allowed for all origins
("url") Allowed for specific origin
interest-cohort=() opts out of Google's FLoC (Federated Learning of Cohorts), a tracking technology that has been replaced by Topics API but may still be relevant in some browser configurations.
The Security Headers Checklist
Header Value Priority
─────────────────────────────────────────────────────────────────────────────
Strict-Transport-Security max-age=31536000; Critical
includeSubDomains; preload
Content-Security-Policy default-src 'self'; ... Critical
X-Content-Type-Options nosniff High
X-Frame-Options DENY or SAMEORIGIN High
Referrer-Policy strict-origin-when-cross- Medium
origin
Permissions-Policy camera=(), microphone=(), ... Medium
X-XSS-Protection 0 (disable, rely on CSP) Low
(legacy browsers only)
Cache-Control no-store (for sensitive pages) Context-
dependent
Note on X-XSS-Protection: Modern browsers have removed their XSS auditors. Set it to 0 to disable the auditor (it had bypass vulnerabilities). Rely on CSP instead.
Testing Your Headers
Use securityheaders.com to scan your site and get a grade. It checks for all major security headers and provides recommendations.
# Command-line check
curl -I https://example.com 2>/dev/null | grep -iE \
"strict-transport|content-security|x-content-type|x-frame|referrer-policy|permissions-policy"
# Or use Mozilla Observatory
# https://observatory.mozilla.org
Example Configuration
# Nginx: security headers for a typical web application
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" 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=()" always;
# Express.js: use helmet middleware
const helmet = require('helmet');
app.use(helmet());
# helmet sets sensible defaults for all security headers
# Customize individual headers:
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://cdn.example.com"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
frameAncestors: ["'none'"]
}
}));
Common Pitfalls
- Using
Access-Control-Allow-Origin: *with credentials. This allows any website to make authenticated requests to your API. Always specify exact origins. - Missing
Vary: Originheader with dynamic CORS. If your CORS response depends on the request origin, omittingVary: Origincauses CDN caching issues where one origin's CORS response is served to another. - Deploying CSP in enforcement mode without testing. Start with
Content-Security-Policy-Report-Onlyto discover what would break. Then switch to enforcement after reviewing reports. - Using
unsafe-inlinein script-src. This negates most of CSP's XSS protection. Use nonces or hashes for inline scripts instead. - Setting headers on the application but not on static assets. Security headers must be set on all responses, including static files served by Nginx, CDN, or S3. Check the response headers for every content type.
- Setting security headers and never checking them again. Infrastructure changes, CDN configurations, and new deployments can remove headers. Include security header checks in your CI/CD pipeline or monitoring.
- Forgetting
frame-ancestorsin CSP. X-Frame-Options is the legacy approach. CSPframe-ancestorsis the modern replacement. Use both for backward compatibility.
Key Takeaways
- CORS controls cross-origin requests. Never use
*for APIs that handle authenticated requests. Validate origins server-side and includeVary: Origin. - CSP is the most effective defense against XSS. Start with
report-onlymode, then enforce. Avoidunsafe-inlineandunsafe-evalin script-src. X-Content-Type-Options: nosniffandX-Frame-Options: DENYare single-line headers with no downside. Set them on every response.Referrer-Policy: strict-origin-when-cross-originprevents URL leakage while maintaining basic analytics.Permissions-Policyrestricts browser feature access. Disable features you do not use (camera, microphone, geolocation).- Test your headers with securityheaders.com or Mozilla Observatory. Include header checks in CI/CD.
- Security headers are set once and forgotten -- which is exactly why they are so often missing after infrastructure changes. Monitor them continuously.