Docs/Server/Security
GitHub

Security

Nix.js Kit includes built-in CSRF protection for server actions, production error sanitization, hardened static serving, default security headers, and body size limits.

# CSRF protection

Server actions accept POST requests to /__nix-js/actions. Without origin verification, a malicious site could submit forms to your actions on behalf of a logged-in user.

# Origin verification

The verifyOrigin() function checks the Origin (or Referer fallback) header against the Host header:

import { verifyOrigin } from "@deijose/nix-js-kit";

// In a custom server setup
const check = verifyOrigin(request);
if (check.denied) {
  return new Response("Forbidden", { status: 403 });
}

# Configuration

Configure CSRF protection via the actionSecurity option:

import { nixJsKit } from "@deijose/nix-js-kit/vite";

export default defineConfig({
  plugins: [
    nixJsKit({
      actionSecurity: {
        enabled: true,          // default: true
        allowedOrigins: [       // additional origins beyond the Host header
          "https://my-app.vercel.app",
          "https://preview.my-app.com",
        ],
        strictOrigin: true,     // reject cross-site requests entirely
      },
    }),
  ],
});

For the SSR server:

import { createSsrServer } from "@deijose/nix-js-kit";

const ssr = await createSsrServer({
  actionSecurity: {
    enabled: true,
    allowedOrigins: ["https://my-app.com"],
  },
});

# OriginCheckOptions

Option Type Default Description
enabled boolean true Enable/disable CSRF checks
allowedOrigins string[] [] Additional allowed origins
strictOrigin boolean false Reject all cross-site requests
ℹ Note

When strictOrigin is false (default), same-site requests are always allowed. When true, only same-origin requests pass.

# Action error cookies

When a server action fails (via fail()), the error data needs to reach the redirected page so the user can see what went wrong.

# The problem with URL params

Some frameworks put error data in URL query parameters (?error=...). This is problematic because:

  • URLs are logged by proxies, CDNs, and analytics
  • URLs appear in browser history
  • Sensitive form data could be exposed

# The solution: ephemeral cookies

Nix.js Kit stores action errors in an ephemeral cookie:

  • Cookie name: __nix_js_action_error
  • SameSite: Lax
  • Max-Age: 15 seconds
  • HttpOnly: false (readable by client if needed, but typically consumed server-side)

The flow:

  1. User submits a form
  2. Action calls fail(400, { message: "Invalid email" })
  3. Server sets the __nix_js_action_error cookie with the error data
  4. Server redirects back to the referring page
  5. renderPage reads the cookie, populates props.form with the error
  6. The response includes a Set-Cookie header that clears the cookie
  7. The client router also clears the cookie after consumption

# Accessing errors in pages

// src/app/contact/page.ts
import { html } from "@deijose/nix-js";
import type { PageProps } from "@deijose/nix-js-kit";

export default function ContactPage({ form }: PageProps) {
  const error = form as { message?: string } | undefined;

  return html`
    <form action="/__nix-js/actions" method="POST">
      <input type="hidden" name="__nix_js_action_name" value="submitContact" />
      <input type="hidden" name="__nix_js_action_page" value="/contact" />
      <input name="email" />
      <button type="submit">Send</button>
    </form>
    ${() => (error?.message ? html`<p class="error">${error.message}</p>` : null)}
  `;
}
💡 Tip

The form prop is automatically populated from the error cookie. You don't need to read the cookie yourself.

# API reference

# verifyOrigin(request, options?)

Returns { denied: boolean, reason?: string }.

# originForbidden(request, options?)

Returns true if the request should be denied based on origin checks.

# encodeActionErrorCookie(data)

Serializes error data for the cookie.

# decodeActionErrorCookie(value)

Deserializes error data from the cookie.

# setActionErrorCookieHeader(data)

Returns a Set-Cookie header string for setting the error cookie.

# clearActionErrorCookieHeader()

Returns a Set-Cookie header string for clearing the error cookie.

The cookie name constant: "__nix_js_action_error".

# Production error sanitization

Since v2.0.2, production error responses no longer expose String(err) — which could leak file paths, stack traces, or secret values. The unified Web handler, action server, and all adapters route errors through publicErrorResponse():

  • Development (dev/preview): full error with stack trace for debugging.
  • Production (start/adapters): a generic 500 page with no internal details. The real error is logged server-side with the request ID.
import { toPublicErrorInfo, publicErrorResponse } from "@deijose/nix-js-kit";

// In a custom handler
const info = toPublicErrorInfo(err, { dev: false });
const response = publicErrorResponse(info, { status: 500 });
⚠ Warning

Never use String(err) or err.stack in a response body. Always route through publicErrorResponse() so the dev/prod distinction is handled automatically.

# Static serving hardening

serveStaticFile() supports full HTTP semantics for static assets:

  • ETag / If-None-Match — 304 Not Modified when the hash matches.
  • Last-Modified / If-Modified-Since — 304 for unchanged files.
  • Range / If-Range — partial content with 206 Partial Content and Content-Range. Single range only; unsatisfiable ranges return 416 Range Not Satisfiable.
  • HEAD — uniform HEAD responses (headers only, no body) for all static files.
  • Accept-Ranges: bytes — advertised on every static response.
  • Immutable caching — hashed assets (/_nix-js/...) get Cache-Control: public, max-age=31536000, immutable.
ℹ Note

Range support means audio/video streaming and large file downloads work correctly without any custom configuration.

# Default security headers

Since v1.3.0, the kit applies a baseline set of security headers to every response. Since v2.0.2, these are applied uniformly through the unified Web handler:

Header Value Purpose
X-Content-Type-Options nosniff Prevent MIME sniffing
Referrer-Policy no-referrer-when-downgrade Limit referrer leakage
X-Frame-Options SAMEORIGIN Clickjacking protection
Permissions-Policy (restrictive defaults) Limit browser API access
Strict-Transport-Security max-age=31536000; includeSubDomains Force HTTPS (when TLS is detected)
Content-Security-Policy (with nonce support) Prevent XSS/injection

Configure or disable headers via securityHeaders in your config or handler options:

import { nixJsKit } from "@deijose/nix-js-kit/vite";

export default defineConfig({
  plugins: [
    nixJsKit({
      securityHeaders: {
        contentSecurityPolicy: { "script-src": ["'self'", "'nonce-{nonce}'"] },
        frameOptions: "DENY",
      },
    }),
  ],
});

To disable security headers entirely:

nixJsKit({ securityHeaders: false })

# Body size limits

Request bodies are size-limited to prevent denial-of-service attacks. The default limit depends on the host's capabilities:

Host Default maxBodySize
Node / Bun (long-lived) Unlimited (OS-level limits apply)
Serverless (Vercel/Netlify) 1 MB
Edge 1 MB

When a body exceeds the limit, the server responds with 413 Payload Too Large before reading the full body. Override via capabilities:

import { createCapabilities } from "@deijose/nix-js-kit/runtime";

const capabilities = createCapabilities({ maxBodySize: 5_000_000 }); // 5 MB
✎ Edit this page on GitHub