5 min read
On this page

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 Origin header on the server and echo back the matching origin.
  • Access-Control-Allow-Credentials: true cannot be used with Access-Control-Allow-Origin: *. The browser rejects this combination.
  • Access-Control-Max-Age caches 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: Origin header with dynamic CORS. If your CORS response depends on the request origin, omitting Vary: Origin causes 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-Only to discover what would break. Then switch to enforcement after reviewing reports.
  • Using unsafe-inline in 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-ancestors in CSP. X-Frame-Options is the legacy approach. CSP frame-ancestors is 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 include Vary: Origin.
  • CSP is the most effective defense against XSS. Start with report-only mode, then enforce. Avoid unsafe-inline and unsafe-eval in script-src.
  • X-Content-Type-Options: nosniff and X-Frame-Options: DENY are single-line headers with no downside. Set them on every response.
  • Referrer-Policy: strict-origin-when-cross-origin prevents URL leakage while maintaining basic analytics.
  • Permissions-Policy restricts 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.