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 |
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:
- User submits a form
- Action calls
fail(400, { message: "Invalid email" }) - Server sets the
__nix_js_action_errorcookie with the error data - Server redirects back to the referring page
renderPagereads the cookie, populatesprops.formwith the error- The response includes a
Set-Cookieheader that clears the cookie - 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)}
`;
}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.
# ACTION_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 generic500page 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 });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 Modifiedwhen the hash matches. Last-Modified/If-Modified-Since—304for unchanged files.Range/If-Range— partial content with206 Partial ContentandContent-Range. Single range only; unsatisfiable ranges return416 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/...) getCache-Control: public, max-age=31536000, immutable.
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