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:
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:
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.
[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:
- 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.
- 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
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:
// 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:
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:
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.
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:
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.