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. 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: all three are 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 by generating 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).
Trusted proxy headers
By default, the framework ignores X-Forwarded-For and X-Real-IP headers. A client can set these to arbitrary values, which — if trusted — would let them defeat IP-based rate limiting and poison audit trails with attacker-chosen IPs.
Only enable trusted proxy headers when your app sits behind a reverse proxy that scrubs or overwrites forwarded headers (e.g. Vercel Edge, Cloudflare, AWS ALB). Call configureTrustedProxy at application startup:
import { defineConfig, configureTrustedProxy } from "@thexjs/core";configureTrustedProxy({ trustForwardedHeaders: true });export default defineConfig({ // ... standard options});The Vercel adapter calls this automatically when it detects it is running on Vercel's edge network. For direct deployments behind nginx, Cloudflare, or similar, add the call yourself. Check the current state with isTrustedProxy().
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.