Security Hardening
A few defaults go a long way: cap the request body so a client cannot exhaust memory, add defensive response headers so browsers enforce content-type sniffing and framing policies, and optionally layer a rate limiter on top. Heximon applies the first two automatically at the dispatch seam — before any handler runs — and ships the rate-limit tier as an opt-in subpath.
Body-size guard
Every incoming request is checked against a maximum body size before any handler or validator reads the
body. A request that declares a Content-Length header exceeding the limit is rejected with a 413RFC 9457 problem+json response:
{
"type": "tag:api.example.com,2026-07-05:BodySizeExceededError",
"status": 413,
"title": "Payload Too Large",
"detail": "Request body size 2097152 bytes exceeds the 1048576 byte limit.",
"instance": "/upload"
}
(The type tag carries your API's host + the error's class name — the same envelope every framework
error renders through.)
The default limit is 1 MiB. Adjust it by setting http.maxBodyBytes in CoreConfig — the host
boot prelude wires the value into RequestDispatcher.configure automatically (no app-side call needed):
// In your app config layering — e.g. an AppConfig that maps env into CoreConfig:
const app = await createApp({
CoreConfig: new CoreConfig({ http: { maxBodyBytes: 5 * 1024 * 1024 } }), // 5 MiB
});
The guard covers both inline and contract-mode controller binding — it sits at the shared dispatch seam, not at the per-route level, so every route is protected automatically.
Security response headers
Every response carries a conservative set of security headers, applied at the same dispatch seam so no route can accidentally omit them:
| Header | Value | Effect |
|---|---|---|
X-Content-Type-Options | nosniff | Prevents browsers from MIME-sniffing a response away from the declared content type. |
X-Frame-Options | DENY | Blocks the page from being embedded in any <iframe> — prevents clickjacking. |
Referrer-Policy | no-referrer | Sends no Referer header on outbound navigations — avoids leaking your URL. |
X-DNS-Prefetch-Control | off | Disables browser DNS prefetching, which can leak visited URLs to DNS resolvers. |
Strict-Transport-Security | max-age=31536000; includeSubDomains | Enforced only over HTTPS. Tells browsers to always use HTTPS for one year and all subdomains. |
HSTS is conditional — it is only emitted when the request arrived over HTTPS, because sending it over HTTP is counterproductive and some environments serve localhost over plain HTTP. The other four headers apply unconditionally.
The header set is applied via SecurityHeadersPolicy, an overridable DI token — bind your own implementation
to replace the defaults.
To skip the pass entirely — for an app that sets these headers at the edge/CDN (Vercel/Netlify/Cloudflare
header config) rather than per response — turn it off in CoreConfig:
const app = await createApp({
CoreConfig: new CoreConfig({ http: { securityHeaders: false } }),
});
The overridable SecurityHeadersPolicy still governs which headers apply while on; this toggle is the
separate "apply them at all" switch (default on — secure by default). A sibling http toggle turns off other
per-request work the same way: requestId: false skips the x-request-id derivation + echo, for apps where
correlation ids are assigned upstream (load balancer / API gateway).
Rate limiting — @heximon/http/security
The rate-limit tier is opt-in and lives in the @heximon/http/security subpath. It is designed for
per-isolate limiting — correct for a single-process Node app, but not distributed across multiple
Cloudflare Workers isolates:
import { RateLimitMiddleware, RateLimitStore, type RateLimitPolicy } from "@heximon/http/security";
import { MemoryRateLimitStore } from "@heximon/http/security";
RateLimitStore — the port
RateLimitStore is the abstract DI token. A concrete implementation owns the window algorithm and its
own atomicity. The built-in MemoryRateLimitStore uses an in-memory sliding window — suitable for
development and single-process production deployments.
RateLimitMiddleware — a local delegating wrapper
RateLimitMiddleware is not itself discovered as a middleware — it's a plain class you construct
directly with a RateLimitStore and a RateLimitPolicy, then delegate to from a local implements Middleware wrapper the HTTP compiler plugin can discover by its own implements Middleware declaration.
This is the pattern the flagship app's own rate-limit demo uses:
import { type HttpAction, type Middleware, type Next } from "@heximon/http";
import { MemoryRateLimitStore, RateLimitMiddleware } from "@heximon/http/security";
import { TimeSpan } from "@heximon/primitives";
/**
* A per-route rate-limit gate — a discovered `implements Middleware` that delegates to the shipped
* {@link RateLimitMiddleware} over its own in-memory store, throttling to 5 requests / 30 s per client IP
* (the shipped default key: `x-forwarded-for`).
*/
export class SecurityRateLimitMiddleware implements Middleware {
/** The shipped rate-limit middleware, built over a dedicated in-memory store + a fixed policy. */
private readonly delegate: RateLimitMiddleware = new RateLimitMiddleware(new MemoryRateLimitStore(), {
limit: 5,
window: new TimeSpan(30, "s"),
});
/**
* Apply the rate-limit gate, delegating to {@link RateLimitMiddleware} (keyed by client IP).
*
* @param action - The matched route's action.
* @param next - The downstream channel.
* @returns The downstream response, or a `429` when the per-IP limit is exceeded.
*/
public handle(action: HttpAction, next: Next): Promise<Response> {
return this.delegate.handle(action, next);
}
}
Provide the wrapper and name it on the route that needs it — either per-route via the action's
middlewares type argument, or module-wide under http: { middlewares }:
import { Module } from "@heximon/runtime";
import { SecurityController } from "./security.controller";
import { SecurityRateLimitMiddleware } from "./security-rate-limit.middleware";
export class SecurityModule extends Module({
providers: [SecurityRateLimitMiddleware],
http: { controllers: [SecurityController] },
}) {}
export class SecurityController implements Controller<"/rate"> {
public async limited(action: Get<"/limited", { middlewares: [SecurityRateLimitMiddleware] }>) {
return { ok: true };
}
}
Overriding handle (or adding a subclass method) also gives you full control over key resolution — for
example, keying on a user id instead of the client IP by overriding resolveKey in a subclass of
RateLimitMiddleware before wrapping it.
Alternative: DI-resolved store via useFactory
If the store itself needs a DI-resolved dependency (a shared Storage binding, a request-scoped
Context), register RateLimitMiddleware through a useFactory provider instead — the factory's
parameter types are the dependency declaration, and the RateLimitPolicy literal is captured in the
factory closure:
import { MemoryRateLimitStore } from "@heximon/http/security";
export class AppRateLimitStore extends MemoryRateLimitStore {}
import { Module } from "@heximon/runtime";
import { TimeSpan } from "@heximon/primitives";
import { RateLimitMiddleware } from "@heximon/http/security";
import { AppRateLimitStore } from "./app-rate-limit.store";
import { ApiController } from "./api.controller";
export class ApiModule extends Module({
providers: [
AppRateLimitStore,
{
provide: RateLimitMiddleware,
useFactory: (store: AppRateLimitStore): RateLimitMiddleware =>
new RateLimitMiddleware(store, { limit: 100, window: new TimeSpan(1, "m") }),
},
],
http: {
controllers: [ApiController],
middlewares: [RateLimitMiddleware],
},
}) {}
A rate-limited request receives a 429 response with a Retry-After header.
MemoryRateLimitStore is per-isolate. State lives in the process's memory, so two Cloudflare Workers
isolates serving the same route do not share a counter. For distributed-correct rate limiting across
isolates, use RateLimitRoomStore from @heximon/durable/security (see below), or implement
RateLimitStore over any networked store. The port's hit(key, policy) method owns the algorithm and
atomicity — swap the impl, not the middleware.Distributed rate limiting — @heximon/durable/security {#distributed-rate-limiting}
MemoryRateLimitStore doesn't share state across isolates — that's a property of memory, not a bug.
When you need a global counter, @heximon/durable/security ships a turnkey RateLimitStore backed by a Cloudflare
Durable Object: one single-threaded DO per rate-limit key, so the sliding-window counter is strongly
consistent everywhere. The port is the swap seam — RateLimitMiddleware, its policy, and the controller
stay identical; only the store provider and two durable module entries change:
import { Module } from "@heximon/runtime";
import { TimeSpan } from "@heximon/primitives";
import { RateLimitRoom, RateLimitRoomFactory, RateLimitRoomStore } from "@heximon/durable/security";
import { RateLimitMiddleware } from "@heximon/http/security";
import { SecurityController } from "./security.controller";
export class ApiModule extends Module({
providers: [
RateLimitRoomStore,
{
provide: RateLimitMiddleware,
useFactory: (store: RateLimitRoomStore) =>
new RateLimitMiddleware(store, { limit: 5, window: new TimeSpan(30, "s") }),
},
],
http: {
controllers: [SecurityController],
middlewares: [RateLimitMiddleware],
},
durable: {
objects: [RateLimitRoom],
factories: [RateLimitRoomFactory],
},
}) {}
The subpath ships the whole Durable Object tier — RateLimitRoom, its RateLimitRoomFactory, and the
concrete RateLimitRoomStore — so the app lists them with no local Durable Object shells; the compiler
recovers the DO's RPC surface and the factory's bound DO from the package's shipped .d.ts.
One DO per key caps throughput at ~500–1,000 req/s per key — RateLimitRoomStore exposes protected sharding hooks to trade
an exact global limit for ~N× throughput on hot keys.
See Durable Objects for how a listed DO is constructed and wired, and
Deploy to Cloudflare for registering DurablePlugin and the generated
wrangler.json bindings.
CSRF posture
Heximon's built-in auth is bearer-token-only (Authorization: Bearer <token>). Classic CSRF attacks work
by exploiting ambient credentials (cookies, <form>-submittable headers) that a browser sends automatically
to a target origin. Bearer tokens are never sent automatically — a cross-origin page cannot read them from
another origin's memory, so it cannot forge a request that carries one.
If your app adds cookie-based sessions, wire a CSRF middleware appropriate for that flow. The bearer-only
design means no CSRF middleware is needed for the default JWTAuthMiddleware setup.
See also
- Authentication — the
JWTAuthMiddlewareandGuardMiddlewarethat pair with the security headers. - Middleware — how to attach middleware per controller or module-wide.
- Security hardening — the body guard, response headers, and a rate-limited endpoint exercised with
curl.
Idempotency
Idempotency tiers for event, queue, and command dispatch — EventIdempotencyInterceptor, CommandIdempotencyInterceptor, QueueIdempotencyInterceptor, Idempotency-Key header, ContextData.idempotencyKey, Storage port, best-effort dedup.
Observability & Tracing
Compile-time, module-attributed OpenTelemetry tracing with OtelPlugin — heximon.module baked onto every HTTP / CommandBus / QueryBus / EventBus span, OTLP export, samplerRatio, batchOptions.