guest@websec: ~/websec/11-http-security-headers — zsh — 100×40 by SaKKo ENTH code:

guest@websec ~ % curl -I https://app.example.com

11 · HTTP Security Headers

A few header lines tell the browser how strict to be with our pages. They cost nothing and block whole kinds of attack.

11.1 The baseline headers

Rails sends most of these for free. Flip each one to see what it blocks:

sim: response header inspectortoggle headers, read the consequences

response

attackoutcome
We already get X-Frame-Options, nosniff, Referrer-Policy and friends on every response. Check what actually arrives with curl -I, since proxies can mess with them.

X-Frame-Options

Stops other sites putting our page in an invisible iframe and tricking users into clicking our buttons (clickjacking). Rails' default SAMEORIGIN is fine.

sim: clickjackingreveal the hidden frame
0%
FREE-PRIZE.TEST · You won a new phone!
(attacker's page)
bank.test/settings · Close account and send balance to linked account?
CONFIRM CLOSE
(your page, framed invisibly)
Click "CLAIM PRIZE".

X-XSS-Protection

An old browser feature that caused more harm than good. Rails switches it off (0). Use CSP instead.

X-Content-Type-Options

Tells the browser not to guess file types, so an "image" full of HTML can't run as a page.

X-Permitted-Cross-Domain-Policies

Stops Adobe products (old Flash, Acrobat) from loading cross-domain policy files from our site. Mostly legacy now, but free and harmless, so keep it.

Referrer-Policy

When users click away, the browser can send our full URL to the next site, tokens and all. The default only sends https://app.test to other sites.

sim: what leaks in the Referer headerfrom https://app.test/reset?token=8f2c…&email=ana%40x.test

Changing the defaults

These defaults live in config.action_dispatch.default_headers. You rarely need to touch them; the last part of this page shows how to tighten one.

11.2 Strict-Transport-Security (HSTS)

Redirecting to HTTPS still leaves the very first request on plain HTTP, which can be hijacked. HSTS tells the browser: "always use HTTPS here" for a year.

force_ssl adds HSTS for us. Only turn on preload once every subdomain is on HTTPS.

11.3 Content-Security-Policy (CSP)

CSP is a list of where scripts are allowed to come from. If XSS slips through, the browser still refuses to run the injected script.

sim: CSP evaluatoredit the policy; each test is checked live
presets:
what the page tries to loaddirectiveresult
Rails generates a CSP file but leaves it all commented out. No CSP until we switch it on.

Reporting violations

Start in Report-Only mode: nothing breaks, but you get reports of what would be blocked. Fix those, then enforce.

Nonces instead of 'unsafe-inline'

'unsafe-inline' lets injected scripts run too, so it mostly defeats CSP. Instead, stamp our own script tags with a random nonce that changes every response. Attackers can't guess it.

# config/initializers/content_security_policy.rb (generated commented out: enable it)
Rails.application.configure do
  config.content_security_policy do |policy|
    policy.default_src :self
    policy.script_src  :self
    policy.object_src  :none
    policy.base_uri    :none
    policy.frame_ancestors :none
    policy.form_action :self
    policy.report_uri  "/csp-violation-report"
  end
  config.content_security_policy_nonce_generator = ->(request) { SecureRandom.base64(16) }
  config.content_security_policy_nonce_directives = %w[script-src]
  # config.content_security_policy_report_only = true     # start here
end
<%= javascript_tag nonce: true do %> init() <% end %>
import crypto from "node:crypto";
import helmet from "helmet";
app.use((req, res, next) => { res.locals.nonce = crypto.randomBytes(16).toString("base64"); next(); });
app.use(helmet.contentSecurityPolicy({ directives: {
  defaultSrc: ["'self'"],
  scriptSrc: [(req, res) => `'nonce-${res.locals.nonce}'`, "'strict-dynamic'"],
  objectSrc: ["'none'"], baseUri: ["'none'"], frameAncestors: ["'none'"],
  reportUri: ["/csp-report"],
}, reportOnly: false }));
// template: <script nonce="<%= nonce %>">…</script>
# Django 6.0+ has built-in CSP (SECURE_CSP setting); this is the django-csp 4.x form
# pip install django-csp   (MIDDLEWARE += ["csp.middleware.CSPMiddleware"])
from csp.constants import NONCE, NONE, SELF, STRICT_DYNAMIC
CONTENT_SECURITY_POLICY = {
    "DIRECTIVES": {
        "default-src": [SELF],
        "script-src": [SELF, NONCE, STRICT_DYNAMIC],   # NONCE: fresh nonce per response
        "object-src": [NONE],
        "base-uri": [NONE],
        "frame-ancestors": [NONE],
        "report-uri": "/csp-report",
    },
}
# template: <script nonce="{{ request.csp_nonce }}">…</script>

11.4 Permissions-Policy (formerly Feature-Policy)

Switch off browser features we don't use (camera, mic, location), so injected code can't ask for them.

sim: Permissions-Policy builderallow only what you use
Also generated commented-out. Off until we enable it.

11.5 Cross-Origin Resource Sharing (CORS)

By default, JS on one site can't read responses from another. CORS is how an API says "this other site may read me". For anything beyond simple requests, the browser asks first (preflight).

CORS loosens security, so list exact origins. Never echo back any origin while allowing cookies; that lets every site read your users' data.

No CORS by default, which is the safe choice. If you add rack-cors, keep the list exact.
sim: CORS decisionconfigure the API, then make a request

api.example.com CORS config



request from browser


# Gemfile: gem "rack-cors"    # config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins "https://app.example.com"      # exact list; never "*" with credentials
    resource "/api/*", headers: %w[Content-Type X-CSRF-Token],
             methods: %i[get post], credentials: true, max_age: 600
  end
end
import cors from "cors";
app.use("/api", cors({
  origin: ["https://app.example.com"],   // exact list; never `origin: true` with credentials
  methods: ["GET", "POST"],
  allowedHeaders: ["Content-Type", "X-CSRF-Token"],
  credentials: true,
  maxAge: 600,
}));
# Django: pip install django-cors-headers
CORS_ALLOWED_ORIGINS = ["https://app.example.com"]   # exact list
CORS_ALLOW_METHODS = ["GET", "POST"]
CORS_ALLOW_CREDENTIALS = True
# Flask: CORS(app, origins=["https://app.example.com"], supports_credentials=True)

11.6 Setting them in your stack

# defaults already sent (config.action_dispatch.default_headers);
# tighten one:
config.action_dispatch.default_headers["X-Frame-Options"] = "DENY"

config.force_ssl = true                     # + Strict-Transport-Security
config.ssl_options = { hsts: { expires: 2.years, subdomains: true } }

# config/initializers/permissions_policy.rb (generated commented out)
Rails.application.config.permissions_policy do |p|
  p.camera :none
  p.microphone :none
  p.geolocation :none
  p.payment :self
end
// npm install helmet: sets a sensible baseline in one line
import helmet from "helmet";
app.use(helmet({
  frameguard: { action: "sameorigin" },               // X-Frame-Options
  referrerPolicy: { policy: "strict-origin-when-cross-origin" },
  hsts: { maxAge: 31536000, includeSubDomains: true },
  // X-XSS-Protection: 0 is sent by default (switches the old filter off)
  // noSniff, permittedCrossDomainPolicies, CSP: on by default
}));
app.use((req, res, next) => {
  res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
  next();
});
# Django settings.py (SecurityMiddleware + XFrameOptionsMiddleware)
X_FRAME_OPTIONS = "SAMEORIGIN"
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin"
# Permissions-Policy: django-permissions-policy, or a small middleware

# Flask: pip install flask-talisman
Talisman(app, frame_options="SAMEORIGIN", strict_transport_security=True,
         referrer_policy="strict-origin-when-cross-origin",
         content_security_policy={"default-src": "'self'"},
         permissions_policy={"camera": "()", "microphone": "()"})

Then verify: run your site through Mozilla's HTTP Observatory or curl -I it in production.