Skip to content

Security

This page consolidates webjs's threat model and hardening surface into one reference. The per-feature pages go deeper; this page is the map of what protects you, what is on by default, and what you must turn on before going live. Pair it with the Deployment checklist for the go-live overlap.

Automatic vs opt-in at a glance

WebJs ships a secure baseline that needs no configuration, plus a set of protections you opt into when your app needs them.

Automatic (on by default, no config):

  • Secure response headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy).
  • CSRF protection on server-action RPC calls (Origin / Sec-Fetch-Site check, no token cookie).
  • Server-only source protection and the browser-reachability gate (the .server boundary).
  • Request body-size limits (413) and connection timeouts (slowloris defense).
  • Subresource Integrity (SRI) on vendor imports, both pinned and live-resolved.
  • Environment variables are server-only unless prefixed WEBJS_PUBLIC_ (fail-closed).
  • Open-redirect protection on declarative redirects and action redirects (same-site paths only).
  • HSTS, but only in production over HTTPS.

Opt-in (you enable when needed):

  • Content-Security-Policy with a per-request nonce (webjs.csp).
  • CORS for cross-origin route handlers (the cors() middleware).
  • Rate limiting (the rateLimit() middleware).
  • A shared session / cache / rate-limit store for horizontal scaling (Redis via setStore).

The .server boundary is the security boundary

The single most important invariant: server-only code goes in .server.{js,ts} files, route.{js,ts} handlers, or middleware.{js,ts}, and never in a page, layout, or component. The .server extension is a path-level boundary. The dev server refuses to serve its source to the browser. A file with a 'use server' directive becomes RPC-callable (its browser import is rewritten to a typed stub that POSTs to the action endpoint); a file without it is a server-only utility (its browser import resolves to a stub that throws at load).

On top of that, only files reachable from a browser-bound entry (a page, layout, error, loading, not-found, or component) are servable at all. The dev server walks the static import graph and any other file (your database connection module, node:* usage, secrets) returns a 404 by construction, the same posture as Next's bundler-manifest model derived statically. So the way to keep a dependency or a secret off the client is the .server boundary, not a runtime check. See No-Build Model for the gate and Server Actions for the RPC model.

CSRF: an Origin / Sec-Fetch-Site check

Server-action RPC calls (the typed stubs generated from a 'use server' import) are CSRF-protected by a cross-origin check, the model Remix 3 and Go 1.25 use. On a state-changing request the server reads the Sec-Fetch-Site fetch-metadata header (browser-set, unforgeable by page JS): same-origin and none pass. When that header is absent (an older browser), it falls back to comparing the Origin host against the request host. A cross-site request is rejected with 403 unless its origin is listed in webjs.allowedOrigins. You write nothing; importing the action and calling it is the whole API, and there is no token or cookie to manage.

Because there is no per-request CSRF cookie, SSR responses carry no Set-Cookie, so a page that opts into a public Cache-Control (via metadata.cacheControl, e.g. on a root layout for a whole visitor-identical app) can be cached at a CDN edge. For a reverse-proxy or multi-domain setup, list the extra origins the check should accept in webjs.allowedOrigins (the check honors x-forwarded-host).

Sharp edge: route.ts REST endpoints are NOT CSRF-protected

A route.ts handler that exposes a server action over REST (whether hand-written or via the route() adapter) is NOT covered by the action RPC's cross-origin check (it is meant for external, often non-browser, consumers). You MUST authenticate every mutating REST endpoint yourself: a bearer token, an API key, an explicit CSRF scheme, or an origin allow-list. Treat a REST endpoint like any public API route, not like an internal action.

Content-Security-Policy (opt-in, nonce-based)

CSP is off by default and enabled with a webjs.csp key in package.json (true for a strict default policy, or an object to customize directives and toggle report-only). When enabled the server mints a fresh per-request CSPRNG nonce, stamps it on the inline boot script, the importmap, and the modulepreload hints, and emits a matching Content-Security-Policy header carrying that exact nonce. One value flows from mint to header, so there is no drift, and it changes every request.

To stamp the nonce on your own inline <script>, read it during SSR with import { cspNonce } from '@webjsdev/core'. For a strict script-src 'self' deploy with no CDN, pair CSP with webjs vendor pin --download so vendor bundles serve from your own origin. See Configuration for the directive reference.

CSP and the client router

The policy is enforced by the Content-Security-Policy response HEADER, never by a <meta http-equiv> tag, so directives like frame-ancestors and report-uri that a meta-tag CSP cannot express work normally. The <meta name="csp-nonce"> tag WebJs emits is not an enforcement mechanism, it is a carrier: the client router reads the nonce from it to stamp any element it inserts.

This matters on a soft navigation. The browser keeps enforcing the ORIGINAL page load's CSP header, because a fetched response's CSP never binds to the already-live document, so the ORIGINAL nonce stays authoritative for the life of that document. The router therefore preserves that first <meta name="csp-nonce"> across every swap and re-stamps each script, module preload, and inline script it inserts with the original nonce (via getCspNonce()), even though the server minted a fresh nonce for the navigation response. A hard reload or a full page load is the harmless case: it creates a NEW document that enforces that response's own fresh nonce, so the nonce stays consistent either way. Nothing gets blocked, and this needs no configuration. It is the client-side equivalent of the Rails and Turbo reuse-the-original-nonce workaround. CSP pages are also excluded from the server HTML cache, so a nonce is never replayed stale.

Secure headers and HSTS

Every response carries a baseline of standard security headers, so a scaffolded app is not clickjackable or MIME-sniffable without a reverse proxy:

  • X-Content-Type-Options: nosniff
  • X-Frame-Options: SAMEORIGIN
  • Referrer-Policy: strict-origin-when-cross-origin
  • Permissions-Policy: camera=(), microphone=(), geolocation=()
  • Strict-Transport-Security, set only in production over HTTPS (detected from the trusted edge proxy's X-Forwarded-Proto).

A default is set only when absent, so a header your middleware or route handler already set is never clobbered. Override or disable a default per path with the webjs.headers config (a value of null drops a default, for example to allow framing on a public-embed route). Precedence runs secure defaults, then the path config, then app middleware (which always wins).

CORS for cross-origin route handlers

A cross-origin browser caller needs CORS. Wrap a route.{js,ts} handler or a middleware.{js,ts} with the cors() middleware, which handles origin reflection, the OPTIONS preflight, and Vary: Origin. One rule is non-negotiable: a credentialed endpoint (credentials: true) REQUIRES an explicit origin allow-list and must never combine with a wildcard '*'. The CORS spec forbids the pair, and reflecting any origin under credentials grants every site credentialed access. cors() narrows a wildcard to the reflected origin to keep the request working but warns once; do not rely on that for a real allow-list.

Request limits: body size and timeouts

Every path that reads a request body enforces a size cap (1 MiB for JSON/RPC, 10 MiB for forms, configurable via webjs.maxBodyBytes / webjs.maxMultipartBytes or the env overrides), returning 413 without buffering an over-limit body whole. The server also bounds connection lifetimes (requestTimeout, headersTimeout, keepAliveTimeout) as a slowloris defense. Both apply with secure defaults; a value of 0 disables a given cap when an edge already enforces it.

Subresource Integrity on vendor imports

Cross-origin vendor modules (resolved from jspm.io) carry a standard SRI integrity hash so a swapped or compromised CDN response cannot execute unverified. This now applies on both paths: a pinned app (webjs vendor pin) ships the hashes in its committed importmap, and an un-pinned app computes them live at warmup. SRI computation is fail-open: a CDN fetch failure during the live path skips that one hash with a warning rather than taking the app down. For reproducible hashes and zero warmup fetches, pin. See No-Build Model.

Sessions and secret management

Session cookies are signed, so set a strong AUTH_SECRET (and SESSION_SECRET where used), 32 or more random characters, in production. Keep all secrets server-only: any process.env name WITHOUT the WEBJS_PUBLIC_ prefix never reaches the browser (reading process.env.DATABASE_URL from a component returns undefined, the same as a typo). The prefix is fail-closed, so a secret cannot leak by accident. Prefer your platform's secret injection over a committed .env file. See Sessions and Auth.

Rate limiting

Protect auth endpoints and other abuse-prone routes with the rateLimit({ window, max }) middleware, placed at any route level (it applies to that subtree). Behind a reverse proxy or CDN, set trustProxy: true so it keys on the forwarded client IP, and make sure the proxy strips an inbound X-Forwarded-For before adding its own. See Rate Limiting.

The REST-endpoint security checklist

Because a route.ts REST endpoint is a public API, restate the rules every time you add one:

  1. Authenticate every mutating endpoint (bearer or API key, an explicit CSRF scheme, or an origin allow-list). A route.ts REST endpoint is NOT CSRF-protected.
  2. Validate input with the validate config export (or the route() adapter's validate option). Never trust a merged {...query, ...params, ...body}.
  3. Log responsibly. No user input or secrets in error messages (in production a thrown server action returns a generic message plus a correlation digest, never the raw message or the stack; the full error is logged server-side).
  4. Configure CORS narrowly with the cors() middleware. A credentialed endpoint requires an explicit origin list, never '*'.
  5. Rate-limit at the edge with rateLimit().

Going live

Before a production deploy, walk the Deployment checklist: terminate TLS at the edge (the production server speaks plain HTTP/1.1), set AUTH_SECRET, point a shared store at Redis if you scale horizontally, enable webjs.csp if your threat model calls for it, and pin vendors. The secure baseline covers the rest with no configuration.