---
url: /reference/generators/security-headers.md
---
# Security headers

```bash
bin/rails g trek:install:security_headers
```

`bin/rails g trek:install` runs it. It sets up the headers that tell browsers what a page may do, so that a script injected into a page (an XSS) can do as little as possible:

| Header | Set by | Default |
| --- | --- | --- |
| `Content-Security-Policy-Report-Only`, then `Content-Security-Policy` | `config/initializers/content_security_policy.rb` | see [below](#content-security-policy) |
| `Permissions-Policy` | `config/initializers/permissions_policy.rb` | camera, microphone, geolocation, payment, USB… off |
| `Strict-Transport-Security` | `config.force_ssl` in production, which `trek:install:config` turns on | 2 years, subdomains included |
| `Referrer-Policy` | Rails | `strict-origin-when-cross-origin`: other sites only see the origin |
| `X-Content-Type-Options`, `X-Frame-Options` | Rails | `nosniff`, `SAMEORIGIN` |

The headers apply to every response of the app: the admin and the front office alike.

An app with its own `content_security_policy.rb` keeps it: the generator only replaces Rails' commented-out example, and says so otherwise. Compare yours with Trek's below.

## Content Security Policy

| Directive | Sources | Why |
| --- | --- | --- |
| `default-src` | `'self'` | anything else comes from the app |
| `script-src` | `'self'`, the nonce | no inline script, unless written with the nonce (`javascript_tag nonce: true`) |
| `style-src` | `'self'`, `'unsafe-inline'` | Trek's components and Backpack's set `style` attributes (colors, custom properties) |
| `font-src` | `'self'`, `data:` | the admin's font, Inter, comes with Trek's npm package |
| `img-src` | `'self'`, `data:`, `blob:`, the upload hosts | uploads come from `S3_BASE_URL` once stored, and from `S3_ENDPOINT` just after upload; `blob:` and `data:` for previews |
| `media-src` | `'self'`, `blob:`, the upload hosts | the same, without `data:` |
| `connect-src` | `'self'` | uploads go through the app's upload endpoint |
| `report-uri` | `/csp-reports` | see [Violation reports](#violation-reports) |
| `object-src` | `'none'` | no plugin |
| `frame-ancestors` | `'self'` | framed by its own pages only (Lookbook), against clickjacking |
| `base-uri`, `form-action` | `'self'` | |

The nonce is the session's, so Turbo, which renders the next pages under the first one's policy, gives their inline scripts a nonce that policy knows. A visitor without a session gets a random one per page: prefer script files to inline scripts on pages Turbo visits before any session.

### Report first, then enforce

The policy only **reports** violations at first (`config.content_security_policy_report_only = true`): a missed source would otherwise break pages. Browse the admin and the front office with the browser's console open, and watch the violations visitors' browsers report (below). Once none is left but noise, set `content_security_policy_report_only` to `false`.

### Violation reports

Browsers send each violation to the policy's `report-uri`: `/csp-reports`, which `Trek::CspReportsController` answers (the generator adds `post "csp-reports"` to the routes). It reports each violation to **AppSignal**, when the app uses it, in a `csp` namespace: one incident per directive and blocked origin (e.g. `script-src-elem https://cdn.example.net`), tagged with the page and whether the policy was enforced, so a new violation is notified once and closed once fixed. To tell what was blocked, each report is also tagged with the browser's user agent and, when the browser gives them, where it happened (`source`: the file's URL, without its query, and the line) and the `sample`: the first 40 characters of an inline script, which the policy asks for with `'report-sample'` (`policy.script_src :self, :report_sample`). Tags are cut to 255 characters, as AppSignal drops longer ones. It logs each one too (`[CSP] script-src-elem blocked inline on /pages/team (report) at https://www.example.com/pages/team:12: "alert(1)"`), which is all an app without AppSignal gets.

It keeps the reports about the app's own pages only, and drops what browser extensions load or inject, which makes most reports and says nothing about the app. It keeps the page's path, not its query, which may carry a token, and the blocked resource's origin, not its URL. Browsers send no CSRF token nor cookie: the endpoint needs neither, and answers `204`.

Anyone can send reports, so a flood mustn't bury the real violations:

* a report must be plausible: a directive CSP has, and a keyword (`inline`, `eval`, `data`…) or a host name blocked;
* AppSignal gets each violation once an hour at most, and 20 different ones an hour at most: the others are only logged;
* 60 reports a minute per IP address at most, and 600 for all of them together.

These counters are kept in `Rails.cache`: with a cache per process or per server (`:memory_store`, `:file_store`), each counts on its own, so the limits multiply. Use a shared store, such as Solid Cache or Redis, when the app runs more than one.

To send the reports elsewhere, e.g. to a service such as [Report URI](https://report-uri.com), set `CSP_REPORT_URI`. Mind that reports hold visitors' page URLs and IP addresses: under the GDPR, such a service processes personal data for the app.

### Adding a source

Each third party the front office uses needs its hosts, in the directives it uses them for:

```ruby
# Analytics
policy.script_src :self, "https://plausible.io"
policy.connect_src :self, "https://plausible.io"

# Embedded videos
policy.frame_src "https://www.youtube-nocookie.com"

# Google Fonts (commented out in the generated policy)
policy.style_src :self, :unsafe_inline, "https://fonts.googleapis.com"
policy.font_src :self, :data, "https://fonts.gstatic.com"
```

A directive written twice replaces the first: keep its sources in one call.

Fonts are better served by the app, as Trek does for the admin's with a [Fontsource](https://fontsource.org) package: the policy needs no extra host, and visitors' IP addresses don't go to Google, which the GDPR would require a reason for.

### A policy for one part of the app

A controller can change the policy for its own pages, e.g. to allow videos on the front office's pages only, and its subclasses inherit the change:

```ruby
class PagesController < ApplicationController
  content_security_policy do |policy|
    policy.frame_src "https://www.youtube-nocookie.com"
  end
end
```

The same way, `Admin::BaseController` can tighten the admin's policy, and `content_security_policy_report_only` can keep one part reporting while the rest is enforced.

## Permissions-Policy

Turns off the browser features the app doesn't use: accelerometer, camera, screen capture, geolocation, gyroscope, HID, magnetometer, microphone, MIDI, payment, serial, USB. Passkeys' WebAuthn stays on. To allow one for the app's own pages, e.g. a map locating the visitor, replace `geolocation=()` with `geolocation=(self)`.

Rails' own `config.permissions_policy` still sends the former `Feature-Policy` header, which browsers are dropping: the initializer sets `Permissions-Policy` itself.

## HSTS

`force_ssl` makes browsers use HTTPS only, for 2 years, on subdomains too (`includeSubDomains`). To be preloaded in browsers, so that even the first visit uses HTTPS, add the `preload` flag in `config/environments/production.rb`, then submit the domain on [hstspreload.org](https://hstspreload.org). Every subdomain must serve HTTPS first: preloading is hard to undo.

```ruby
config.ssl_options = { hsts: { subdomains: true, preload: true } }
```

## Checking

Once deployed, [Mozilla's HTTP Observatory](https://developer.mozilla.org/en-US/observatory) grades the headers. The report-only policy doesn't count: enforce it to reach an A.
