---
url: /site/security-headers.md
---
# Security Headers&#x20;

Since 2.12 every web response of an Evolve site carries a baseline of security headers: the frontend, the panel and the image route alike. They are defence in depth. None of them fixes a vulnerability on its own, but together they stop the browser from guessing content types, stop other sites from framing the site, keep full URLs out of cross-origin referrers and pin live sites to https.

## Default behaviour

The `SecurityHeaders` middleware is pushed into the `web` middleware group by the package. No project change is needed.

| Header | Default value | Sent on |
|---|---|---|
| `X-Content-Type-Options` | `nosniff` | every web response |
| `X-Frame-Options` | `SAMEORIGIN` | every web response |
| `Referrer-Policy` | `strict-origin-when-cross-origin` | every web response |
| `Strict-Transport-Security` | `max-age=31536000` | https requests of a **live** environment only |
| `Content-Security-Policy` | none | only when `EVOLVE_CSP` is set |

Rules the middleware follows:

* **Headers the app already set win.** A header a controller, another middleware or a package set on the response is left alone. This is how a project overrides a single header for a single route.
* **Blank values are skipped.** Setting a header's value to empty turns that header off.
* **HSTS only on live over https.** Staging hosts often run on self-signed or preview certificates, and HSTS there would lock browsers out of the certificate click-through for a whole year. The environment type comes from `EVOLVE_ENV_TYPE` (or `APP_ENV`), see [Environments](/getting-started/environments). No `includeSubDomains`, so subdomains of the customer's domain are not affected.
* **Only the `web` group.** Routes outside it, such as `/api/health-check`, get no headers from the middleware.

## Configuration

Config lives under `evolve.security-headers` in `config/evolve.php`:

```php [config/evolve.php]
'security-headers' => [
    'enabled' => env('EVOLVE_SECURITY_HEADERS', true),
    'headers' => [
        'X-Content-Type-Options' => 'nosniff',
        'X-Frame-Options' => env('EVOLVE_FRAME_OPTIONS', 'SAMEORIGIN'),
        'Referrer-Policy' => 'strict-origin-when-cross-origin',
    ],
    'hsts' => env('EVOLVE_HSTS', 'max-age=31536000'),
    'content-security-policy' => env('EVOLVE_CSP'),
    'content-security-policy-report-only' => env('EVOLVE_CSP_REPORT_ONLY', false),
],
```

| Config key | Env variable | Default | Effect |
|---|---|---|---|
| `security-headers.enabled` | `EVOLVE_SECURITY_HEADERS` | `true` | `false` switches the middleware off entirely. |
| `security-headers.headers.X-Frame-Options` | `EVOLVE_FRAME_OPTIONS` | `SAMEORIGIN` | Empty disables the header, for sites embedded on another domain. |
| `security-headers.hsts` | `EVOLVE_HSTS` | `max-age=31536000` | The HSTS value on live. Empty disables HSTS. |
| `security-headers.content-security-policy` | `EVOLVE_CSP` | unset | The policy. Unset sends no CSP header. |
| `security-headers.content-security-policy-report-only` | `EVOLVE_CSP_REPORT_ONLY` | `false` | `true` sends the policy as `Content-Security-Policy-Report-Only`. |
| `security-headers.headers` | | the three above | Add or change headers per project in a published config. |

A project that publishes `config/evolve.php` can add any header to `headers`, for example a `Permissions-Policy`.

## Embedding the site in an iframe

`X-Frame-Options: SAMEORIGIN` stops browsers from rendering the site inside an iframe on another origin. The panel's page preview is same-origin and keeps working. A site that is embedded elsewhere on purpose (a booking widget on a partner site, a microsite inside a customer portal) needs the header off:

```ini [.env]
EVOLVE_FRAME_OPTIONS=
```

::: warning Check the .htaccess too
The evolve-starter `public/.htaccess` ends with `Header set X-Content-Type-Options "nosniff"` and `Header set X-Frame-Options "SAMEORIGIN"`, and many older projects carry the same lines. Apache applies them to every response, including those from PHP. Depending on how PHP runs, they either replace what the app sent or, under PHP-FPM, add a second header next to it (`X-Frame-Options: DENY` and `X-Frame-Options: SAMEORIGIN` in one response). On such a site `EVOLVE_FRAME_OPTIONS=` alone changes nothing. Replace both lines with the block from [Pages from the static page cache](#pages-from-the-static-page-cache).
:::

To allow only specific origins to frame the site, leave `X-Frame-Options` off and use the CSP directive `frame-ancestors` instead, which `X-Frame-Options` cannot express:

```ini [.env]
EVOLVE_FRAME_OPTIONS=
EVOLVE_CSP="frame-ancestors 'self' https://portal.partner.ch"
```

## Rolling out a Content-Security-Policy

A CSP is opt-in because Evolve themes rely on inline scripts and on tag managers that load further scripts at runtime. A strict policy set blindly breaks tracking, embeds and Alpine components. Roll it out in two steps:

1. **Report only.** Set the policy with `EVOLVE_CSP_REPORT_ONLY=true`. Browsers enforce nothing and log every violation in the developer console (and send reports if the policy names a `report-uri` or `report-to` endpoint). Click through the site with consent granted, open pages with videos, maps, forms and the shop, and extend the policy until the console stays quiet.
2. **Enforce.** Remove `EVOLVE_CSP_REPORT_ONLY` (or set it to `false`). The same policy is now sent as `Content-Security-Policy`.

A starting point for a site with Google Tag Manager and Vimeo embeds, to be tuned per site:

```ini [.env]
EVOLVE_CSP="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://*.google-analytics.com https://player.vimeo.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://*.google-analytics.com https://*.analytics.google.com; frame-src https://player.vimeo.com https://www.youtube-nocookie.com; frame-ancestors 'self'"
EVOLVE_CSP_REPORT_ONLY=true
```

The policy is one header value, so quote it in the `.env`. `'unsafe-eval'` is there because Alpine evaluates its expressions at runtime.

## Pages from the static page cache

The [page cache](/site/page-cache) writes rendered pages to `public/page-cache/<path>/index.html` (and `index.json` for JSON requests), and the rewrite rules in `public/.htaccess` hand those files out directly. PHP never runs for a cache hit, so the middleware sets no headers on it. Images served from `public/img` bypass PHP the same way. On a site with `APP_PAGE_CACHE=true` the headers must also come from the web server.

Onlime runs Apache, so add this to `public/.htaccess`, outside the `mod_rewrite` block, and delete the starter's two `Header set X-Content-Type-Options` and `Header set X-Frame-Options` lines at the end of the file:

```apache [public/.htaccess]
<IfModule mod_headers.c>
    # Same baseline as the Evolve middleware, for responses PHP never sees
    # (page cache hits, public/img). PHP responses are skipped: the middleware
    # sets these itself, and Apache cannot see headers PHP-FPM sent.
    <If "%{REQUEST_FILENAME} !~ /\.php$/">
        Header setifempty X-Content-Type-Options "nosniff"
        Header setifempty X-Frame-Options "SAMEORIGIN"
        Header setifempty Referrer-Policy "strict-origin-when-cross-origin"

        # HSTS on the live domain over https only. Put the real host in.
        Header setifempty Strict-Transport-Security "max-age=31536000" "expr=%{HTTPS} == 'on' && %{SERVER_NAME} == 'www.example.ch'"
    </If>
</IfModule>
```

* The `<If>` keeps the block off every request Apache hands to PHP (the resolved file is `index.php`), so PHP responses carry exactly the middleware's values and `EVOLVE_FRAME_OPTIONS` still decides for them. `setifempty` alone is not enough: under PHP-FPM (`mod_proxy_fcgi`) Apache keeps the headers PHP sent in a table that `Header` without `always` does not look at, so every header would go out twice.
* `setifempty` still guards against a value set elsewhere in the Apache config. Needs Apache 2.4.10 or later (for `expr=`).
* The `.htaccess` is deployed to every environment. The host condition keeps HSTS off staging, where the middleware would not send it either. `SERVER_NAME` is the requested host without a port, so `www.example.ch:443` matches as well.
* A site embedded in an iframe drops the `X-Frame-Options` line here as well.
* Mirror an enforced CSP here once it is stable, with the same condition style.

## Cache actions are POST only

In the same release the panel's cache actions stopped accepting GET requests. A GET that changes state can be triggered from any other site by a link or an image while an admin is logged in (cross-site request forgery).

| Route name | Path |
|---|---|
| `evolve.cache.clear` | `POST /evolve/cache/clear` |
| `evolve.cache.flush` | `POST /evolve/cache/flush` |
| `evolve.cache.config.clear` | `POST /evolve/cache/config/clear` |
| `evolve.cache.image.clear` | `POST /evolve/cache/image/clear` |
| `evolve.cache.view.clear` | `POST /evolve/cache/view/clear` |
| `evolve.cache.route.clear` | `POST /evolve/cache/route/clear` |
| `evolve.cache.page.rebuild` | `POST /evolve/cache/page/rebuild` |
| `evolve.cache.page.clear` | `POST /evolve/cache/page/clear` |

The panel submits them through a hidden form with a CSRF token; the layout is unchanged. A GET to any old `/evolve/cache/...` URL redirects to the cache overview without doing anything, so links in views published before 2.12 do not fail, they just stop clearing. The info bar's cache icon on frontend pages opens the cache overview instead of flushing directly: the info bar is part of the page HTML, and a CSRF token there could end up in the static page cache.

A project with its own view or script that calls one of these routes must send a POST with the CSRF token:

```blade
<form method="POST" action="{{ route('evolve.cache.page.clear') }}">
    @csrf
    <button type="submit">Clear page cache</button>
</form>
```

## Server-side fetches

Two places fetched URLs on the server and were tightened in 2.12:

* `Util::toInlineStyles()` only fetches `https` URLs whose host is listed in `evolve.inline-styles-hosts` (default `fonts.googleapis.com`), with timeouts and without following redirects. Any other URL, or a failed fetch, renders a plain `<link rel="stylesheet">`. Its argument must never be user input.
* The store locator's geocoder rejects empty input and input over 255 characters without calling Google, times out after 5 seconds, and caches results under namespaced keys (`locator.geocode.<sha1>`). Hits are cached forever, "no results" for one hour, API errors not at all. Old cache entries are no longer read, so every address is geocoded once more after the update.

```php [config/evolve.php]
'inline-styles-hosts' => [
    'fonts.googleapis.com',
    'use.typekit.net',
],
```

## Pitfalls

* **An iframe embed goes blank after the update.** `EVOLVE_FRAME_OPTIONS=` in the server `.env`, and check the `.htaccess` (see above).
* **No HSTS on live.** The environment type is not live (`EVOLVE_ENV_TYPE` unset and `APP_ENV` not `production`), or the request did not reach PHP as https. Behind a TLS-terminating proxy or load balancer, Laravel's trusted proxies must be configured, otherwise every request looks like http.
* **HSTS is sticky.** Browsers remember it for the whole `max-age` and refuse plain http for that host until it expires. Make sure the live host keeps a valid certificate. To withdraw it, send `EVOLVE_HSTS=max-age=0` for a while instead of removing the header.
* **The CSP breaks tracking silently.** GTM loads scripts the policy may block, and the page itself looks fine. Always run report-only first and check that tags still fire (GTM preview, GA4 DebugView).
* **Web server and app disagree.** A plain `Header set` in `.htaccess` replaces the value the app sent or, under PHP-FPM, adds a second header next to it, so `.env` changes seem to have no effect. Use the block above, which leaves PHP responses alone.
* **A header appears twice.** `curl -sI` shows two `X-Frame-Options` or `X-Content-Type-Options` lines on a PHP page: the starter's `Header set` lines, or a `setifempty` block without the `<If>`, are still in the `.htaccess`.

## Verifying on a server

```bash
# A page rendered by PHP (the query string bypasses the page cache)
curl -sI "https://www.example.ch/?nocache=1" | grep -iE 'x-content-type|x-frame|referrer-policy|strict-transport|content-security'

# A page served from the page cache (request it twice; a cache hit sets no session cookie)
curl -sI https://www.example.ch/ | grep -iE 'x-content-type|x-frame|referrer-policy|strict-transport|set-cookie'
```

On live over https both show all four headers; on staging `Strict-Transport-Security` is missing, by design. [securityheaders.com](https://securityheaders.com) gives the same answer for a public URL.
