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:
response
| attack | outcome |
|---|
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.
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.
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.
| what the page tries to load | directive | result |
|---|
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.
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.
rack-cors, keep the list exact.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.