Security

Security

x ships with production-grade security guardrails enabled by default — build-time env isolation, CSRF protection on server actions, security headers on every response, and an in-memory rate limiter. All of it is configurable or disableable per environment.

Configuration overview

Security options live under the security key in x.config.ts:

x.config.ts
import { defineConfig } from "@thexjs/core";export default defineConfig({  // ...pagesDir, apiDir, etc.  security: {    csrf: {      allowedOrigins: ["https://app.example.com"],      requireToken: true,    },    headers: {      contentSecurityPolicy: "default-src 'self'; script-src 'self'",      hstsMaxAge: 31536000,    },    rateLimit: {      limit: 100,      windowMs: 60_000,    },  },});

To disable any guardrail entirely, pass false:

disable all security
security: {  csrf: false,  headers: false,  rateLimit: false,}

Build-time env isolation

Only variables prefixed with THEXJS_PUBLIC_ may ever reach browser code. During x build, the bundler scans every client-shipped bundle for references to process.env.*, Bun.env.*, or import.meta.env.* that are not public — and if it finds any, the build halts with an EnvLeakageError.

build error
[x] server-only environment variable(s) leaked into client bundle "src/components/widget.tsx":  DATABASE_URL, STRIPE_SECRET_KEY.  Only "THEXJS_PUBLIC_*" variables may be referenced in client-shipped code   move this access into a loader, server function, or API route.

This check runs on island hydration bundles too, so even selectively-hydrated components can't accidentally ship secrets. Move env access into a loader or server function and pass the value as loaderData.

CSRF protection

All POST requests to /__x/actions/* (server functions) are automatically verified. Two independent checks are available:

  1. Origin/Referer verification — rejects cross-site requests whose Origin or Referer doesn't match the app's own origin. This is always on unless CSRF is disabled entirely.
  2. Double-submit token — when requireToken: true is set, a random token is issued in a cookie and must be echoed back in the x-csrf-token header on mutating requests.

Options

CsrfOptions
interface CsrfOptions {  allowedOrigins?: string[];   // e.g. ["https://app.example.com"]  requireToken?: boolean;      // default: false  disabled?: boolean;          // default: false}

Using CSRF tokens from the browser

When requireToken is enabled, read the cookie and send it back as a header:

client
// The cookie is set automatically by the server on the first response.// Read it and echo it on every POST to /__x/actions/*.function getCsrfToken() {  const match = document.cookie.match(/(?:^|;s*)x_csrf_token=([^;]+)/);  return match ? match[1] : "";}await fetch("/__x/actions/greet/greet", {  method: "POST",  headers: {    "Content-Type": "application/json",    "x-csrf-token": getCsrfToken(),  },  body: JSON.stringify(["world"]),});

Security headers

Every response gets a set of security headers by default. These are applied by applySecurityHeaders in the request pipeline and can be customized or disabled:

SecurityHeadersOptions
interface SecurityHeadersOptions {  contentSecurityPolicy?: string | false;   // default: conservative same-origin CSP  hstsMaxAge?: number | false;              // default: 15552000 (180 days)  hstsIncludeSubDomains?: boolean;          // default: true  frameOptions?: string | false;            // default: "DENY"  contentTypeOptions?: string | false;      // default: "nosniff"  referrerPolicy?: string | false;          // default: "strict-origin-when-cross-origin"}

The default CSP allows inline styles (for Tailwind) but blocks inline scripts and external resources. If you need to allow a specific domain, override the entire value:

custom CSP
security: {  headers: {    contentSecurityPolicy:      "default-src 'self'; script-src 'self' 'unsafe-inline'; img-src 'self' data: https://images.unsplash.com",  },}

Rate limiting

A lightweight in-memory rate limiter is applied ahead of all routing. It uses a fixed-window counter keyed by client IP (from x-forwarded-for or x-real-ip). When the limit is exceeded, the server returns a 429 Too Many Requests with a Retry-After header.

RateLimitOptions
interface RateLimitOptions {  limit?: number;                     // default: 60 requests  windowMs?: number;                  // default: 60_000 (1 minute)  keyFn?: (req: Request) => string;   // custom bucket key (e.g. by user ID)}

This is a single-process limiter — for multi-instance deployments, front it with a shared store (Redis, etc.) via a custom keyFn or use a reverse proxy with its own rate limiting.

Disabling security

For local development or testing, you can disable everything:

disable all
export default defineConfig({  security: {    csrf: false,    headers: false,    rateLimit: false,  },});

Individual guards can be toggled independently. CSRF and headers are on by default in production; rate limiting is on by default in both dev and production.