Manual contentsThe BasicsBrowse 136 chapters
Manual 14 min read

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.

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:

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:

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:

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:

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:

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.

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:

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:

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():

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:

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 receives nonce as Option<&str>. Match it before writing a script. Askama escapes the attribute value:

{% 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:

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:

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:

// 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:

// 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:

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 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:

    <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:

    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 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:

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 , :

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 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.

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