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:
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.
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:
- 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 token must be echoed 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}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/*:
import { withCsrfCookie } from "@thexjs/core";export async function POST(req: Request) { // ...create a session... return withCsrfCookie(req, Response.json({ ok: true }));}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:
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", },}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.
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):
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:
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.