GioJSdocs
On this page

Security

Security headers, Content-Security-Policy with per-request nonces, CSRF protection, and WebSocket origin checks - enforced by the Rust server.

Every protection on this page runs in the Rust HTTP layer, on every response and before any request reaches your Node code. Two of them are on without any configuration: the default security headers and cross-site request (CSRF) protection. A Content-Security-Policy is opt-in because only you know which third-party origins your app loads. Everything is configured in the [security] section of gio.toml; a misspelled key there stops the server at startup instead of silently leaving a protection off.

Every protection here can be turned off or loosened, and startup warns when one is: Turning Protections On and Off lists them all. To roll out a policy step by step, follow the Content Security Policy guide.

Default security headers

Every response - pages, cache hits, route handlers, static and public/ files, redirects, errors and /_gio endpoints - carries:

HeaderValueWhy
X-Content-Type-OptionsnosniffBrowsers never guess a script or stylesheet out of an upload served as text or an image.
X-Frame-OptionsSAMEORIGINOther sites cannot frame your pages (clickjacking).
Referrer-Policystrict-origin-when-cross-originFull URLs (with their query strings) are only sent to your own origin.
Strict-Transport-Securitymax-age=31536000Only when [server.tls] is enabled - see HSTS.

X-Powered-By is always removed. The headers are added when a response is sent, never stored in the page cache, so a config change applies to cached pages immediately.

Change, remove or add default headers in [security.headers]. An empty value removes a default. Headers that can break an app - Permissions-Policy, Cross-Origin-Opener-Policy, Cross-Origin-Resource-Policy - are not sent unless you add them here:

toml
[security.headers]
x-frame-options = "DENY"                 # override a default
referrer-policy = ""                     # remove a default
permissions-policy = "camera=(), microphone=(), geolocation=()"
cross-origin-opener-policy = "same-origin"
cross-origin-resource-policy = "same-site"

To drop all three built-in headers at once - a CDN or proxy in front sets its own - use [security] default_headers = false. Startup then logs a warning. Entries in [security.headers] are still sent (and an empty value there still removes a single header), and HSTS keeps its own hsts setting.

toml
[security]
default_headers = false                  # no nosniff, X-Frame-Options or Referrer-Policy

[security.headers]
x-content-type-options = "nosniff"       # but keep this one

Precedence

A default never replaces a header the response already has. Headers set by your app - a route handler's Response headers, headers returned from getServerSideProps - and by [[headers]] or middleware.ts header rules win. A rule with an empty value removes the default for its paths only, for example to let partners frame one section:

toml
[[headers]]
path = "/embed/*rest"
[headers.headers]
x-frame-options = ""            # no X-Frame-Options on /embed/...

HSTS

Strict-Transport-Security tells browsers to use HTTPS only. GioJS sends it automatically only when it terminates TLS itself ([server.tls] enabled = true). Behind a proxy that terminates TLS, GioJS sees plain HTTP and cannot know your site is HTTPS-only, so you turn it on explicitly - it is then sent on every response:

toml
[security]
hsts = true                                     # max-age=31536000
# hsts = { max_age = 63072000, include_subdomains = true, preload = true }
# hsts = "max-age=63072000; includeSubDomains"  # raw value
# hsts = false                                  # never, even with [server.tls]
Only enable include_subdomains and preload when every subdomain serves HTTPS - browsers remember the policy for max_age seconds and preload lists are hard to leave.

Content-Security-Policy

A CSP with a nonce is the strongest defense against cross-site scripting: the browser runs only scripts carrying the nonce of the current response, so markup an attacker manages to inject cannot execute. Write {nonce} where the nonce goes:

toml
[security]
csp = """
  default-src 'self';
  script-src 'self' 'nonce-{nonce}' 'strict-dynamic';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data:;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'self'
"""

Every response gets a fresh, random 192-bit nonce, and every script GioJS writes carries it: the hydration bootstrap and its module preloads, React's streaming Suspense scripts, the deployment script, the critical-CSS loader, and in development the error overlay. Line breaks in the policy are allowed; they are sent as spaces.

How nonces work with caching

Pages are cached and PPR shells are replayed, so a page cannot be rendered with the nonce of the response that will eventually carry it. Instead the Node worker renders with a secret placeholder, and the Rust server replaces it with the response's nonce every time it serves the page - fresh renders, cache hits, stale-while-revalidate, PPR shells and their streamed holes, and streaming SSR alike, before compression. The placeholder is random and never sent to a browser, so a stored-XSS string cannot contain it and can never be turned into a valid nonce.

The replacement covers the headers and body of every dynamic response - pages, route handlers and SSE streams, whatever their content type (HTML, JSON, JavaScript, CSS, XML, ...) - so even a route handler that echoes cspNonce() sends the response's nonce, not the placeholder. The nonce is exactly as long as the placeholder, so Content-Length and byte ranges stay valid. Only public/ and build assets are served untouched. A dynamic response that sets its own Content-Encoding (a body your handler compressed itself) cannot be searched, so while nonces are on it is refused with a 500 and an error in the server log: drop the header and let GioJS compress the response.

The placeholder is kept in the page cache directory's meta/ (.gio/cache/pages/meta/ by default) so the disk cache stays valid across restarts. The worker needs it before it builds, so it changes with what the deployment ID covers apart from that build: a new GIO_DEPLOYMENT_ID, a new standalone build, or a change to the gio.toml settings pages render with. A code-only redeploy keeps it (its cached pages are still dropped: the cache is keyed by the full deployment ID). To rotate it, delete .gio/cache/pages/meta/csp-nonce-placeholder-* and restart; cached pages are then rendered again. Turning CSP on or off invalidates cached pages automatically.

Your own inline scripts: cspNonce()

Inline scripts you write need the nonce too. cspNonce() from @gio.js/core returns it during server rendering (or undefined when no CSP uses {nonce}). It is meant for nonce attributes only: pass it straight to the attribute - its value is only final in the response, so never hash, slice or encode it or derive anything else from it. Put inline scripts in the root layout, which is server-rendered only:

app/layout.tsx
import { cspNonce } from '@gio.js/core';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <script
          nonce={cspNonce()}
          dangerouslySetInnerHTML={{ __html: "document.documentElement.dataset.theme = localStorage.theme ?? 'light'" }}
        />
        <script nonce={cspNonce()} async src="https://analytics.example.com/script.js" />
      </head>
      <body>{children}</body>
    </html>
  );
}
  • With 'strict-dynamic', scripts loaded by a nonced script are trusted too, so code splitting and client-side navigation (which imports the next route's chunk) keep working, and host allowlists in script-src are ignored by modern browsers - nonce third-party <script src> tags as above.
  • Inline event handler attributes (onclick="...") and javascript: URLs cannot carry a nonce and are blocked. React's onClick props are not affected - they are attached by JavaScript.
  • Styles: keep style-src 'self' 'unsafe-inline', without a nonce. React renders style props as style="..." attributes, which only 'unsafe-inline' allows, and <Animate> and <GioLink> view transitions add inline <style> elements that React hoists into the head without a nonce (<Animate> sets a style attribute as well, and so does <GioImage> with fill or placeholder="blur"). Adding 'nonce-{nonce}' or a hash to style-src makes browsers ignore 'unsafe-inline' and blocks all of these. GioJS's critical CSS and built-in 404 page carry the nonce, so a nonce-only style-src is possible only for an app that uses no style props, no <Animate>, no <GioLink> transitions and no <GioImage> with fill or a blur placeholder.
  • Static export (gio export) has no server to set the header or the nonces; cspNonce() returns undefined there.

Rolling out with report-only

csp_report_only takes the same syntax and is sent as Content-Security-Policy-Report-Only: browsers report violations in the console (or to a report-uri) without blocking anything. Both may be set at once and share the response's nonce. A page or route handler that sets its own Content-Security-Policy header keeps it; an empty [[headers]] value removes the policy for those paths.

CSRF protection

On by default. Requests that change state - every method except GET, HEAD, OPTIONS and TRACE - to pages and route handlers are checked before the body is read and before Node sees them, using the headers browsers attach to every request:

RequestResult
Sec-Fetch-Site: same-origin or none (typed URL, bookmark)allowed
Sec-Fetch-Site: cross-site403
Sec-Fetch-Site: same-site (a sibling subdomain)403 - subdomains can be controlled by someone else
no Sec-Fetch-Site, Origin equals the request's hostallowed
no Sec-Fetch-Site, any other Origin (including null)403
neither header (curl, webhooks, server-to-server calls)allowed - not sent by a browser page, so it cannot be forged by one
Origin listed in trusted_originsallowed
path listed in exemptnot checked
toml
[security.csrf]
enabled = true                                       # default
trusted_origins = ["https://admin.example.com"]      # scheme://host[:port]
exempt = [                                           # same patterns as [[redirects]]
  "/api/webhooks/*rest",                             # webhooks
  "/auth/callback/apple",                            # OAuth/OIDC response_mode=form_post
  "/saml/acs",                                       # SAML assertion consumer service
]
Some legitimate requests are cross-site form posts by design: another site's page posts the user's browser back to you, so the browser labels the request Sec-Fetch-Site: cross-site. OAuth/OpenID Connect callbacks with response_mode=form_post (Sign in with Apple, Microsoft Entra ID / Azure AD), SAML assertion consumer service (ACS) endpoints, and 3-D Secure or other payment-provider returns all work this way, and get a 403 until you list their paths in exempt - check yours when upgrading. Such endpoints must verify the request themselves (signature, state or RelayState, assertion), which they do anyway. Webhooks called server to server send neither header and pass without an entry; exempt them only if the sender adds an Origin.

Exempt patterns use the middleware rule syntax and match the normalized path, so spelling variants of a URL cannot dodge or abuse an entry. A refused request gets a 403 whose plain-text body names the setting to change, and is logged once per origin. Rust's own /_gio endpoints have their own checks.

enabled = false turns the check off, for apps whose forms carry their own CSRF tokens; startup logs a warning naming the key. Prefer trusted_origins or exempt when only some origins or paths need it.

CSRF protection covers state-changing methods only. Keep GET handlers free of side effects, and keep setting SameSite=Lax (or Strict) on session cookies as a second layer.

Route handlers add one more guard: req.json() only parses bodies sent with Content-Type: application/json (or application/*+json). Anything else - including the text/plain and form bodies an HTML form on another site can send without a CORS preflight - makes it throw UnsupportedMediaTypeError, answered with 415 unless you catch it. req.body always has the raw body.

WebSocket origin checks

Browsers let any website open a WebSocket to your server and send your users' cookies with it (cross-site WebSocket hijacking). Upgrade requests get the same check as unsafe methods: an Origin from your own host or from [security.csrf] trusted_origins is accepted, as is a client that sends no Origin (not a browser); anything else is refused with 403 before the connection is upgraded. [security.csrf] exempt paths are skipped here too - exempt a public WebSocket API meant to be used from any site.

The check has its own switch and stays on when you set [security.csrf] enabled = false (for example because your forms carry their own CSRF tokens) - token-protected forms do nothing for WebSockets. Turning it off logs a warning at startup:

toml
[security.websocket]
check_origin = true      # default; false accepts upgrades from any website

Behind a reverse proxy

The CSRF and WebSocket checks compare Origin with the host the browser addressed. That is the Host header GioJS receives, unless the request comes from a proxy listed in [server] trusted_proxies: then its X-Forwarded-Host (or the host= of Forwarded, with proxy_headers = "forwarded") counts instead. So the proxy must either pass the original host through, as nginx does with:

nginx
location / {
    proxy_pass       http://127.0.0.1:3000;
    proxy_set_header Host $host;   # Origin is compared with it
}

or send the public host in a forwarding header, from an address you list in trusted_proxies:

toml
[server]
trusted_proxies = ["127.0.0.1"]   # the proxy's address or CIDR; its X-Forwarded-Host counts

A proxy that rewrites Host to an internal name and is not listed in trusted_proxies makes every same-origin browser request look cross-origin (403). If you can do neither, list your public origin in trusted_origins. When the proxy terminates TLS, also set hsts explicitly (see HSTS).

Reference

toml
[security]
default_headers = true    # false drops nosniff, X-Frame-Options and Referrer-Policy
csp = "default-src 'self'; script-src 'self' 'nonce-{nonce}' 'strict-dynamic'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'"
csp_report_only = ""      # same syntax, sent as Content-Security-Policy-Report-Only
hsts = true               # unset: only with [server.tls]; true | false | "raw" | { max_age, include_subdomains, preload }

[security.headers]        # override ("value"), remove (""), or add default headers
permissions-policy = "camera=()"

[security.csrf]
enabled = true
trusted_origins = []      # e.g. ["https://admin.example.com"]
exempt = []               # e.g. ["/api/webhooks/*rest", "/auth/callback/apple"]

[security.websocket]
check_origin = true       # independent of [security.csrf] enabled