Skip to main content
Documentation

Security

The built-in CSRF same-origin floor for actions, plus the middleware recipe for API routes and ambient-auth apps.

Applies to v1.0.0-alpha.15 · Updated

Standing assumption

Generated action POST handlers ship with a fail-closed same-origin floor: a request is rejected when Sec-Fetch-Site is cross-site, when Origin is present and does not match the request URL origin, or when Sec-Fetch-Site is same-site but Origin is missing or null — a forged header, since browsers always send Origin on POST. Loopback hostname aliases (localhost / 127.0.0.1 / [::1]) count as the same origin. Clients that omit both headers (typical non-browser tools) are allowed. Set OPEN_ELEMENT_DISABLE_CSRF=1 on the request env binding (c.env / Nitro runtime env) to opt out. Framework-owned session APIs are not in the current contract; provider recipes own their cookie/session transport and must apply the same-origin floor plus explicit cookie attributes.

"The request URL origin" is the origin the server derives for the incoming request, which is not the same thing as the scheme and host a browser used when a TLS-terminating proxy sits in front: the server sees plain HTTP. A deployment behind one states its public origin (OPEN_ELEMENT_PUBLIC_ORIGIN, or OPEN_ELEMENT_TRUST_PROXY=1 for a port only a trusted proxy can reach) — see Behind a proxy. Without that, a genuine same-origin POST is rejected rather than admitted; the floor itself never relaxes.

Private build artifacts

The build's request-time server output — dist/server/ with the entry, the SSR bundle and the request-time manifest — is server code, never a public asset. oe start / npm start serves only the public output and answers GET/HEAD for that namespace with a bare 404, including percent-encoded spellings, case variants, interior .., doubled slashes, query strings, and an in-root symlink whose canonical target resolves inside dist/server/; the server bundle cannot be read over HTTP. The rule lives in the shared static boundary that start and --mode=preview both use — it is not a host configuration that can be missed. A request-time route the application itself declares under the reserved segment keeps its precedence and is still handled by the application, but the static namespace is never served. For host static layers (a Nitro publicAssets directory) keep copying "everything except server/", as the deployment guide shows.

Ambient authentication

Apps authenticated by ambient credentials — HTTP Basic, mTLS, or cookies set with SameSite=None — cannot rely on the Lax default: the browser attaches those credentials to cross-site requests. The built-in same-origin floor covers generated action POSTs, but such apps should validate the request origin on every state-changing API route as well.

Redirect targets

redirect() validates the status (3xx whitelist) but never the location — targets are author-controlled code, as in SvelteKit. An action that redirects to user-supplied input (a ?next= parameter, a stored URL) is an open redirect: validate or allowlist such targets in the action before calling redirect().

Middleware recipe

The built-in floor guards generated action handlers only. For custom API routes — and as defense in depth for ambient-auth apps — drop the middleware below into app/routes/_middleware.ts. A root _middleware.ts default-exports a WinterCG fetch middleware (request, next) => Promise<Response> scoped to /*, in front of every page action and API route. It allows safe methods and same-site Fetch Metadata, and falls back to an Origin allowlist for older browsers.

app/routes/_middleware.ts

// CSRF guard for custom API routes and defense in depth:
// generated action POST handlers already enforce a fail-closed same-origin
// floor (opt out with OPEN_ELEMENT_DISABLE_CSRF=1 on the request env). Apps
// using ambient authentication (Basic, mTLS, SameSite=None cookies) should
// also reject cross-site state-changing requests on their API routes.
const ALLOWED_ORIGINS = new Set(['https://app.example.com']);

export default async function csrfGuard(request: Request, next: () => Promise<Response>) {
  const method = request.method;
  if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') {
    return next();
  }
  // Fetch Metadata: same-origin/same-site submissions and user-typed
  // navigations are always fine.
  const site = request.headers.get('sec-fetch-site');
  if (site === 'same-origin' || site === 'same-site' || site === 'none') {
    return next();
  }
  // Older browsers without Fetch Metadata: fall back to the Origin header.
  const origin = request.headers.get('origin');
  if (origin && ALLOWED_ORIGINS.has(new URL(origin).origin)) {
    return next();
  }
  return new Response('Forbidden', { status: 403 });
}

middleware.corsOrigin (the openElement() option) governs cross-origin resource sharing only — it is not a CSRF check. The two compose: CORS for reads, this guard for writes. Guards that span the whole app rather than a route subtree can also ride middleware.use — the dialect-free fetch middleware chain composed at the handler boundary with identical dev/start/Nitro semantics (see Configuration → middleware.use).

Content Security Policy

middleware.csp ({ policy, nonce, reportOnly }) emits a Content-Security-Policy on request-time responses. Like every built-in middleware it guards the request-time dispatch path only: prerendered static pages never pass through the middleware chain, so SSG output carries the policy as a build-time <meta http-equiv="Content-Security-Policy"> tag instead of an HTTP header.

With nonce: true the generated server mints a per-request nonce (a random 32-hex value, fresh on every request), exposes it to the render pipeline as the request-scoped cspNonce, and prepends 'nonce-<value>' to script-src (adding a script-src directive when the policy has none). Every framework-generated <script> — the island client entry, the dev client, styled 404/500 pages — is attached through wrapInDocument's script descriptors, the single attachment point, so each one carries the nonce of its own response. Pair the nonce with script-src 'strict-dynamic': the nonced island client entry may then import its chunks without a host allowlist. Raw head fragments can never inject <script> (see the HTML trust boundary below), so the descriptor pipeline stays the only script source. Structured data is not a bypass of that rule: structuredData takes plain objects (never markup), the framework serializes them into a single application/ld+json element itself, and every < in the payload is escaped to \u003C, so a value can never close the tag or open a new one.

Nonces are per-request by construction and therefore impossible in static files: a pure-static (SSG) build with nonce: true logs a warning and falls back to a policy-only <meta> tag. That fallback is fail-closed — with script-src 'strict-dynamic' the policy-only page has no nonce source and no allowlist, so the browser blocks every script and islands never hydrate. For pure-static sites either write a nonce-free policy (for example script-src 'self', which covers the external island client) or keep nonce: true only on projects that serve request-time routes.

Set reportOnly: true to swap the header (and the SSG meta tag) to Content-Security-Policy-Report-Only: the browser reports violations without enforcing them, which is the rollout posture for a new policy.

vite.config.ts — CSP with per-request nonce

import { defineConfig } from 'vite';
import { openElement } from '@openelement/router/vite';

export default defineConfig({
  plugins: [
    openElement({
      middleware: {
        csp: {
          nonce: true,
          policy:
            "default-src 'self'; script-src 'strict-dynamic'; style-src 'self' 'unsafe-inline'",
        },
      },
    }),
  ],
});

HTML trust boundary

trustedHtml is the framework's explicit trust boundary for HTML: only values created by trustedHtml() reach html Parts and innerHTML sinks — ordinary strings are rejected at render time. The framework ships no HTML sanitizer, so sanitize untrusted fragments (user input, CMS output, third-party HTML) at your own system boundary, before the data enters the framework. Raw head fragments (headExtras, inject.headFragments) are likewise developer-trusted input; the framework enforces only no-<script> and no-executable-<style>.

See also

  • Configuration — the middleware, CORS and CSP options referenced above.
  • Deployment — what the request-time handler enforces once it is deployed.
  • Error Handling — the status codes and problem details these guards answer with.