# Security Headers

`SecurityHeaders` adds browser security policy to responses through Suprnova's
own middleware chain. New projects in every kit, including API projects, register the balanced preset.
Existing applications opt in by registering the middleware; upgrading alone
does not change their responses.

## Register the middleware

Register it first in the HTTP bootstrap, before logging, `Inertia::install`,
error pages and RenderCache. The framework also sorts `SecurityHeaders` to the
outermost application position regardless of registration order or middleware priority.
Use typed registration: `global_middleware!`, `MiddlewareRegistry::append` or
`prepend`, or `Server::middleware`. Those APIs retain the type that ordering
and the server error boundary need; a manually erased middleware closure
does not retain that information. The built-in request-id scope stays outside
the application stack. Typed registration also retains a copy of the configured
policy beside the middleware's identity. The server reads that copy from its
own registry snapshot for health and catalogue responses and the WebSocket
response boundary. These paths need no application middleware execution and
keep the same configured policy across panic recovery.
Route and group security overrides run immediately inside it, before RenderCache
lookup. The response returns through security headers last, so cache hits and
error pages retain their policy.

```rust,ignore
use suprnova::{FrameworkError, SecurityHeaders, global_middleware};

pub fn try_register_http_stack() -> Result<(), FrameworkError> {
    global_middleware!(SecurityHeaders::from_env()?);
    // Register the remaining HTTP middleware here.
    Ok(())
}
```

The scaffold keeps a `register_http_stack()` wrapper for the application's
HTTP bootstrap hook. It calls `try_register_http_stack()` and fails at boot if
configuration is invalid. The fallible sibling also returns Inertia install errors. A server-substituted error page keeps the other
security headers and supplies its own CSP, which allows no script.

## Choose a preset

Use `SecurityHeaders::strict()`, `SecurityHeaders::balanced()` or
`SecurityHeaders::relaxed()` for programmatic configuration. `from_env()` reads
the variables below and defaults to balanced. `SecurityHeaders::off()` supplies
no headers.

| Header | Strict | Balanced | Relaxed |
|---|---|---|---|
| `Content-Security-Policy` | Strict policy below | Balanced policy below | Omitted |
| `Strict-Transport-Security` | `max-age=63072000; includeSubDomains` | `max-age=31536000` | `max-age=31536000` |
| `X-Frame-Options` | `DENY` | `SAMEORIGIN` | `SAMEORIGIN` |
| `X-Content-Type-Options` | `nosniff` | `nosniff` | `nosniff` |
| `Referrer-Policy` | `no-referrer` | `strict-origin-when-cross-origin` | `strict-origin-when-cross-origin` |
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=(), browsing-topics=(), display-capture=()` | Strict's list without `payment=()` | Omitted |
| `Cross-Origin-Opener-Policy` | `same-origin` | `same-origin-allow-popups` | Omitted |
| `Cross-Origin-Embedder-Policy` | `require-corp` | Omitted | Omitted |
| `Cross-Origin-Resource-Policy` | `same-origin` | `same-site` | Omitted |

Strict's CSP is:

```text
default-src 'self'; script-src 'nonce-N' 'strict-dynamic'; style-src 'self' 'nonce-N'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'
```

Balanced's CSP is:

```text
default-src 'self'; script-src 'self' 'nonce-N'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data: https:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'
```

`N` represents the fresh request nonce. Balanced runs script files from the
application's own origin, such as the Live components' scripts under
`/suprnova-ui/`, and any script that carries the nonce. An inline script an
attacker injects carries no nonce and is blocked. Strict adds
`'strict-dynamic'` and drops `'self'`, so under strict every script tag needs
the nonce, the Live components' tags included. Balanced permits inline style
attributes because components and kits use them. Adding a nonce to `style-src`
would disable that permission. Strict requires authorized style elements and
does not permit inline style attributes.

In `APP_ENV=local` or `development`, the framework also permits the configured
Vite dev server in `script-src`, `style-src`, `font-src` and `img-src`, and
its origin and HMR websocket in `connect-src`. The stylesheet links come from
the dev server, and so do the fonts and images they point at. It reads the same configuration as the Inertia Vite integration,
so `suprnova serve` works with the balanced policy.

## Configure headers

The `header` builder changes or removes an individual header. Header names and
values are validated and errors return `FrameworkError`:

```rust,ignore
use suprnova::{FrameworkError, SecurityHeaders};

fn policy() -> Result<SecurityHeaders, FrameworkError> {
    SecurityHeaders::balanced()
        .header("referrer-policy", Some("same-origin"))?
        .header("cross-origin-opener-policy", None)
}
```

Use `.content_security_policy(Some(policy))` to set a typed
`ContentSecurityPolicy`, or `.content_security_policy(None)` to remove it.
`ContentSecurityPolicy::strict()` and `::balanced()` provide the built-in
policies; `ContentSecurityPolicy::new(template)?` validates a custom policy.
The `{nonce}` placeholder expands to the request nonce:

```rust,ignore
use suprnova::{ContentSecurityPolicy, FrameworkError, SecurityHeaders};

fn custom_policy() -> Result<SecurityHeaders, FrameworkError> {
    let csp = ContentSecurityPolicy::new(
        "default-src 'self'; script-src 'nonce-{nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'",
    )?;
    Ok(SecurityHeaders::balanced().content_security_policy(Some(csp)))
}
```

A header already set on the response wins. This preserves the policy on
Live's transport and upload responses and any policy a handler supplies.

### Add a source to the policy

To let the page reach another origin, add that origin to one directive of the
policy you already have, rather than writing a whole policy.
`.csp_source(directive, source)` adds one source and keeps the preset that
`SECURITY_HEADERS` selected, its report-only mode and its reporting origins:

```rust,ignore
use suprnova::{FrameworkError, SecurityHeaders};

fn policy() -> Result<SecurityHeaders, FrameworkError> {
    SecurityHeaders::from_env()?
        .csp_source("connect-src", "https://plausible.io")?
        .csp_source("img-src", "https://images.example.com")
}
```

With the balanced preset, the first call turns `connect-src 'self'` into
`connect-src 'self' https://plausible.io` and changes nothing else. The source
is added to the enforcing policy and to a report-only policy alike, so a
policy under test does not report the connection the enforcing one allows.
The relaxed and off presets send no policy, and the call leaves them as they
are.

A directive the policy leaves out starts from the sources it falls back to,
so adding one source narrows nothing. The strict preset has no `frame-src`,
so `.csp_source("frame-src", "https://www.youtube-nocookie.com")` writes
`frame-src 'self' https://www.youtube-nocookie.com`, the `default-src` sources
plus the new one. A directive with no fallback in the policy, such as
`form-action` in a policy without it, already allows every source and is
unchanged.

`ContentSecurityPolicy::with_source(directive, source)` does the same to one
policy value. Both return an error for a directive that takes no sources,
such as `sandbox`, and for a source that is not one expression: an empty
value, whitespace, a `;` or `,`, two sources in one call, `'none'`, or a nonce
other than `'nonce-{nonce}'`. They do not check the expression's grammar
beyond that. Call
`.csp_source` after `.content_security_policy(...)` or a `header(...)` call
that sets a policy, since those replace the policy and its added sources.

## Environment and reporting

| Variable | Default | Values |
|---|---|---|
| `SECURITY_HEADERS` | `balanced` | `strict`, `balanced`, `relaxed`, `off` |
| `SECURITY_HEADERS_CSP_REPORT_ONLY` | `false` | Boolean |
| `SECURITY_HEADERS_CSP_REPORT_TO` | Unset | A reporting endpoint URL |
| `SECURITY_HEADERS_HSTS_PRELOAD` | `false` | Boolean |

`SecurityHeaders::from_env()` returns an error naming a variable when its
value is invalid. See [Environment Variables](env-vars.md).

Report-only mode sends `Content-Security-Policy-Report-Only` in place of
`Content-Security-Policy`. It reports violations without enforcing that CSP.
Enable it while checking a policy against your application:

```env
SECURITY_HEADERS=balanced
SECURITY_HEADERS_CSP_REPORT_ONLY=true
SECURITY_HEADERS_CSP_REPORT_TO=https://reports.example.com/csp
SECURITY_HEADERS_HSTS_PRELOAD=false
```

A reporting URL adds a CSP `report-to` directive and a `Reporting-Endpoints`
header identifying its destination. Your application or reporting service
must accept the browser's violation reports. Programmatic equivalents are
`.report_only(true)` and `.report_to(Some("https://reports.example.com/csp"))?`.
Pass `None` to `report_to` to remove the destination.

The enforcing and report-only headers are independent policies. To keep an
enforcing policy while testing another, add the reporting policy by name:

```rust,ignore
let headers = suprnova::SecurityHeaders::strict().header(
    "Content-Security-Policy-Report-Only",
    Some("default-src 'self'; script-src 'nonce-{nonce}'; object-src 'none'"),
)?;
```

Both policies use the same request nonce. A nonce is generated when either
policy needs one, and each nonce policy is emitted only for HTML. Setting or
removing either header by name changes only that header. `.report_only(true)`
moves the preset's policy to report-only; `.report_only(false)` moves it back.
The typed `.content_security_policy(...)` builder updates the policy in that
selected mode.

## HSTS and HTTPS

`Strict-Transport-Security` is sent only when `Request::secure()` is true and
never in the local environment. Behind a TLS-terminating proxy, configure
`APP_TRUSTED_PROXIES` so the framework can trust its forwarded scheme. An
untrusted client's forwarded header cannot turn on HSTS.

The strict preset includes subdomains; balanced and relaxed do not. Preload is
opt-in through `SECURITY_HEADERS_HSTS_PRELOAD=true` or `.hsts_preload(true)`.
Choose it only when the domain and its subdomains meet the browser preload
requirements. Setting the token does not submit the domain to a preload list.

## Read and use the nonce

For a nonce policy, the middleware generates 128 random bits encoded as
unpadded base64url before it calls the handler. Each request gets a different
value. Read it through `request.csp_nonce()` or `CspNonce::current()`:

```rust,ignore
use suprnova::{CspNonce, Request};

fn request_nonce(request: &Request) -> Option<String> {
    request.csp_nonce().or_else(CspNonce::current)
}
```

Tokio does not inherit task-local values automatically. Capture the nonce scope
before spawning work that renders request assets:

```rust,ignore
let task = tokio::spawn(suprnova::CspNonce::propagate(async {
    suprnova::CspNonce::current()
}));
let nonce = task.await?;
```

The request task's context makes `CspNonce::current()` available to view and
head rendering code that holds no request. Without a nonce policy it returns
`None`. A JSON or other non-HTML response gets the other configured security
headers and no nonce-bearing CSP.

The framework stamps its executable scripts and script or style links:

- Inertia's default document and application root template `head` part include
  `<meta property="csp-nonce" nonce="...">`. Vite reads this element; each
  scaffold entry passes its nonce to `createInertiaApp` for injected styles.
- Vite's development preamble, scripts, modulepreload links and stylesheets
  carry it, including `Vite::tags` and `Vite::to_html` outside Inertia.
- `Head` stamps scripts and `preload`, `modulepreload` and `stylesheet` links.
  Preconnect, DNS-prefetch and prefetch hints need no nonce.
- Live's bootstrap uses it when the caller has not selected a nonce with
  `LiveBootstrapOptions::with_nonce`. Its scripts, modulepreload, preload and
  stylesheet links carry it.

Inertia page data and Live configuration use non-executing JSON elements and
need no nonce. Without a nonce policy, framework markup keeps its previous
bytes.

### Add an application script

An [Inertia root template](frontend-inertia-responses.md) receives `nonce` as
`Option<&str>`. Match it before writing a script. Askama escapes the attribute
value:

```html
{% if let Some(nonce) = nonce %}
<script nonce="{{ nonce }}" src="https://js.stripe.com/v3/"></script>
{% endif %}
```

The same shape authorizes an analytics tag or a script you host yourself. An
Askama view outside Inertia can receive `CspNonce::current()` as a field. Copy
the nonce only from the current request.

The nonce lets the script run. What the script then loads still meets the
rest of the policy, and the balanced policy's `default-src 'self'` blocks
another origin's frames, API calls and the scripts it loads itself. Stripe.js,
for example, also needs its own origin, its frames and its API. Add them to
the policy with `.csp_source`:

```rust,ignore
let headers = suprnova::SecurityHeaders::from_env()?
    .csp_source("script-src", "https://js.stripe.com")?
    .csp_source("connect-src", "https://api.stripe.com")?
    .csp_source("frame-src", "https://js.stripe.com")?
    .csp_source("frame-src", "https://hooks.stripe.com")?;
```

Balanced leaves `payment` out of `Permissions-Policy`, so Stripe's Apple Pay
and Google Pay buttons work, and sends `Cross-Origin-Opener-Policy:
same-origin-allow-popups`, so a popup sign-in or checkout window can report
back to the page. Strict turns both off.

To permit another origin's scripts without a nonce on each tag, add the origin
to `script-src`. Under strict this does not work: `'strict-dynamic'` makes
supporting browsers ignore origin allowlists, so give the tag the nonce
instead. For example:

```rust,ignore
let headers = suprnova::SecurityHeaders::balanced()
    .csp_source("script-src", "https://analytics.example.com")?;
```

A nonce authorizes a script to execute. It does not make its contents trusted.
Choose external scripts with the same care as application dependencies.

## Override a route or group

Attach `SecurityHeadersOverride` as ordinary route or group middleware. It
records changes for the outer global middleware to apply.

Building an override can fail, because `header` validates the name and value,
and `routes!` cannot return that error: it defines `pub fn register() ->
Router` from its list. Build the overrides in a function that returns a
`Result`, and register their routes on top of the router `register()` builds:

```rust,ignore
// src/routes.rs
use suprnova::{FrameworkError, Router, SecurityHeadersOverride, get, group, routes};

use crate::controllers;

// The routes that need no override.
routes! {
    get!("/", controllers::home::index),
}

/// Every route, with the two that carry an override.
pub fn try_register() -> Result<Router, FrameworkError> {
    let route_policy = SecurityHeadersOverride::new()
        .header("referrer-policy", Some("no-referrer"))?;
    let group_policy = SecurityHeadersOverride::new()
        .header("cross-origin-resource-policy", None)?;
    let router = get!("/report", controllers::reports::show)
        .middleware(route_policy)
        .register(register());
    Ok(group!("/shared", {
        get!("/image", controllers::images::show),
    })
    .middleware(group_policy)
    .register(router))
}
```

Then call `routes::try_register()` where `cmd/main.rs` calls
`routes::register()`. In a scaffolded project that is the routes hook, which
already returns a `Result`:

```rust,ignore
// cmd/main.rs
.try_routes_async(|| async {
    live::routes_with_render_cache(agents::serve(routes::try_register()?)?).await
})
```

An application without the Live routes passes the function itself to
`Application::try_routes(routes::try_register)`.

Use `.content_security_policy(None)` on the override to turn off the policy
in the global builder's selected mode for that route or group. Use the named
header API to control the enforcing and report-only policies independently:

```rust,ignore
let route_policy = suprnova::SecurityHeadersOverride::new()
    .header("Content-Security-Policy-Report-Only", None)?;
```

This removes only the report-only policy. An inherited enforcing CSP keeps its
nonce. Removing the enforcing header likewise leaves a report-only policy
and its nonce intact. A route override can change or turn off any middleware header;
a header the handler already set still wins. Review frame permissions together:
`X-Frame-Options` and CSP's `frame-ancestors` should describe the same access.

## WebMCP and Permissions-Policy

WebMCP, the browser API through which [agents](agents.md) call a page's
tools, is gated by the `tools` permission. The WebMCP specification makes
`tools` a policy-controlled feature whose default allowlist is `'self'`, and
`document.modelContext.registerTool()` rejects with `NotAllowedError` in a
document that may not use it. Chrome's origin trial follows the same rule.

No preset names `tools` in its `Permissions-Policy`, so the default applies
under every preset: the application's own pages, and frames of the same
origin, register tools. A frame from another origin does not, unless the page
that embeds it delegates the permission. Nothing needs to change for WebMCP
to work on your own pages.

To let one of your tool pages run inside an iframe on another site, two
things must hold:

1. The embedding page delegates the permission on its iframe:

   ```html
   <iframe src="https://app.example.com/orders" allow="tools"></iframe>
   ```

   If the embedding page sends a `Permissions-Policy` header that names
   `tools`, its allowlist must also include your origin, for example
   `tools=(self "https://app.example.com")`.

2. Your page lets that site frame it. Strict sends `X-Frame-Options: DENY`
   and `frame-ancestors 'none'`, and balanced sends `SAMEORIGIN` and
   `frame-ancestors 'self'`, so both refuse a frame from another origin. Add
   the embedding origin to `frame-ancestors` and drop `X-Frame-Options`, which
   cannot name another origin:

   ```rust,ignore
   use suprnova::{FrameworkError, SecurityHeaders};

   fn headers() -> Result<SecurityHeaders, FrameworkError> {
       SecurityHeaders::balanced()
           .csp_source("frame-ancestors", "https://portal.example.com")?
           .header("x-frame-options", None)
   }
   ```

   To open only some routes to framing, give them a `SecurityHeadersOverride`
   with their own policy instead, as [Override a route or
   group](#override-a-route-or-group) shows.

The framework's OAuth approval, connected-agents and claim pages always refuse
to be framed, whatever the preset.

To turn WebMCP off for the whole application, add `tools=()` to the preset's
list:

```rust,ignore
let headers = suprnova::SecurityHeaders::balanced().header(
    "permissions-policy",
    Some("camera=(), microphone=(), geolocation=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=(), browsing-topics=(), display-capture=(), tools=()"),
)?;
```

### The Origin Trial

Chrome runs WebMCP for real users as an origin trial from Chrome 149. Register
your origin for the trial and set `WEBMCP_ORIGIN_TRIAL_TOKEN` to the token it
issues. For several tokens, such as separate Chrome and Edge tokens, set a
comma-separated list. Every HTML response carries one header, with the tokens
joined by `, `:

```text
Origin-Trial: <chrome-token>, <edge-token>
```

The server adds the header as each response leaves, after all middleware, so
error pages, static `.html` files and plain views carry it, as Inertia and
Live pages do. JSON, redirects, assets and tool answers do not, because only a
document enables a trial. A response that already carries the same token keeps
it once. The header does not depend on `SecurityHeaders`: it is sent whether or
not the middleware is registered. A value that is not base64 stops boot,
naming the variable and the bad entry's position, never its value. Tokens are
trimmed and empty entries are skipped; an empty list sends nothing. The
[Origin Trials developer guide](https://github.com/GoogleChrome/OriginTrials/blob/gh-pages/developer-guide.md#15-can-i-provide-multiple-tokens-on-a-page)
allows comma-separated tokens in one header. A test registers a
token with `Config::register(WebMcpConfig::new().with_origin_trial_token(token)?)`.

## Cache nonce pages

A Complete page under a nonce CSP renders on every request. RenderCache never
stores that complete rendering, since replaying it would reuse a nonce. A
stitched Live document can be cached: each hit fills its nonce holes and
nonce-bearing headers with the current request's nonce, so body and CSP agree.
After a nonce policy is enabled, an entry published without it must render
again and replace the stored entry before replay. A stitched page with no
nonce-bearing content can then replay as Complete; it still records that it
was published under the nonce policy.
See [RenderCache](render-cache.md).

### Why Suprnova diverges

Laravel ships `FrameGuard` for `X-Frame-Options` and `Vite::useCspNonce` for asset
tags, but no CSP, HSTS or policy middleware. Applications use packages such as
`spatie/laravel-csp`. Suprnova includes `SecurityHeaders` so its own Inertia,
Live and RenderCache writers share one per-request nonce and its scaffolds
start with a browser policy.

## Next

- [Middleware](middleware.md) covers global order and route middleware.
- [Inertia Responses](frontend-inertia-responses.md) covers application root documents.
- [Environment Variables](env-vars.md) lists every configuration setting.
- [Agents](agents.md) covers the tools WebMCP registers.
