newX 1.3 — islands to disk, server-mode islands, image proxy

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 a per-IP 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.

The low-level pieces are exported too: findLeakedEnvKeys(code), assertNoEnvLeakage(code, file), and the PUBLIC_ENV_PREFIX constant — handy for custom build tooling or CI checks.

CSRF protection

All requests to /__x/actions/* (server functions) are 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 token must be echoed 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}

Issuing the token cookie

The token cookie is not set automatically: your app sets it once, typically when a session starts (a login route), by wrapping the response with withCsrfCookie — or generate one yourself with generateCsrfToken. From the browser, read the cookie and echo it on every POST to /__x/actions/*:

login route (sets the cookie)
import { withCsrfCookie } from "@thexjs/core";export async function POST(req: Request) {  // ...create a session...  return withCsrfCookie(req, Response.json({ ok: true }));}
client (echoes the token)
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"]),});

The other primitives are exported for custom pipelines: checkCsrf, verifyOrigin, verifyCsrfToken.

The same module runs automatically on @thexjs/auth endpoints: its POST routes (signin, signout) call checkCsrf under the hood, so auth mutations get the same Origin/Referer (and optional double-submit token) verification as server functions, no extra wiring needed.

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",  },}

To reuse the header builder without the request pipeline, call buildSecurityHeaders(options) directly.

Rate limiting

A lightweight fixed-window rate limiter is applied ahead of all routing. Buckets are keyed by the client's real IP — resolved from the underlying socket (Bun server.requestIP), falling back to x-forwarded-for / 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)  store?: RateLimitStore;             // shared store for multi-instance}

For multi-instance deployments, share counters with Redis via createRedisRateLimitStore (uses Bun's built-in bun:redis, no npm dependency):

x.config.ts
import { defineConfig, createRedisRateLimitStore } from "@thexjs/core";export default defineConfig({  security: {    rateLimit: {      limit: 120,      store: createRedisRateLimitStore({ url: process.env.REDIS_URL }),    },  },});

createRateLimiter and rateLimitMiddleware are also exported if you want to apply limits to a specific handler yourself (e.g. a login endpoint with a tighter budget).

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.