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 ;
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 ;
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 ;
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 ;
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 = strict.header?;
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 ;
Tokio does not inherit task-local values automatically. Capture the nonce scope before spawning work that renders request assets:
let task = spawn;
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
headpart include<meta property="csp-nonce" nonce="...">. Vite reads this element; each scaffold entry passes its nonce tocreateInertiaAppfor injected styles. - Vite's development preamble, scripts, modulepreload links and stylesheets
carry it, including
Vite::tagsandVite::to_htmloutside Inertia. Headstamps scripts andpreload,modulepreloadandstylesheetlinks. 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 %}
{% 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 = from_env?
.csp_source?
.csp_source?
.csp_source?
.csp_source?;
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 = balanced
.csp_source?;
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 ;
use cratecontrollers;
// The routes that need no override.
routes!
/// Every route, with the two that carry an override.
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
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 = new
.header?;
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:
-
The embedding page delegates the permission on its iframe:
If the embedding page sends a
Permissions-Policyheader that namestools, its allowlist must also include your origin, for exampletools=(self "https://app.example.com"). -
Your page lets that site frame it. Strict sends
X-Frame-Options: DENYandframe-ancestors 'none', and balanced sendsSAMEORIGINandframe-ancestors 'self', so both refuse a frame from another origin. Add the embedding origin toframe-ancestorsand dropX-Frame-Options, which cannot name another origin:use ;To open only some routes to framing, give them a
SecurityHeadersOverridewith 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 = balanced.header?;
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
- Middleware covers global order and route middleware.
- Inertia Responses covers application root documents.
- Environment Variables lists every configuration setting.
- Agents covers the tools WebMCP registers.
