Security
Keryx ships with security defaults that are sensible for development and tightenable for production. Most features are configured via environment variables — no code changes needed to go from development to a hardened production deployment.
Rate Limiting
Rate limiting uses a sliding window algorithm backed by Redis. It's implemented as action middleware, so you can apply it to specific actions or leave it off entirely.
Setup
Add RateLimitMiddleware to any action:
import { RateLimitMiddleware } from "keryx";
export class UserCreate implements Action {
name = "user:create";
middleware = [RateLimitMiddleware];
// ...
}The middleware identifies clients by user ID (if authenticated) or IP address (if not), and applies different limits to each:
| Config Key | Env Var | Default | Description |
|---|---|---|---|
enabled | RATE_LIMIT_ENABLED | true | Master toggle (disabled in test) |
windowMs | RATE_LIMIT_WINDOW_MS | 60000 | Sliding window size (ms) |
unauthenticatedLimit | RATE_LIMIT_UNAUTH_LIMIT | 20 | Max requests per window (no session) |
authenticatedLimit | RATE_LIMIT_AUTH_LIMIT | 200 | Max requests per window (logged in) |
keyPrefix | RATE_LIMIT_KEY_PREFIX | "ratelimit" | Redis key prefix |
When a client exceeds the limit, the action returns a 429 with a message indicating how many seconds until the window resets. Rate limit info is also included in response headers (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset).
Custom Limits
The checkRateLimit() function is exported for use outside of action middleware — for example, the OAuth registration endpoint uses it with a stricter limit:
import { checkRateLimit } from "keryx";
const info = await checkRateLimit(`oauth-register:${ip}`, false, {
limit: config.rateLimit.oauthRegisterLimit, // default: 5
windowMs: config.rateLimit.oauthRegisterWindowMs, // default: 1 hour
});Security Headers
Every HTTP response includes security headers by default. Each is configurable via environment variable:
| Header | Env Var | Default |
|---|---|---|
Content-Security-Policy | WEB_SECURITY_CSP | default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' https://cdn.jsdelivr.net data:; img-src 'self' data: blob:; connect-src 'self'; worker-src blob: |
X-Content-Type-Options | WEB_SECURITY_CONTENT_TYPE_OPTIONS | nosniff |
X-Frame-Options | WEB_SECURITY_FRAME_OPTIONS | DENY |
Strict-Transport-Security | WEB_SECURITY_HSTS | max-age=31536000; includeSubDomains |
Referrer-Policy | WEB_SECURITY_REFERRER_POLICY | strict-origin-when-cross-origin |
These defaults are production-ready. The CSP allows loading scripts, styles, and fonts from cdn.jsdelivr.net (used by the built-in Swagger UI and OAuth pages). Tighten or adjust via WEB_SECURITY_CSP for your needs.
Cookie Security
Session cookies are configured with security flags:
| Config Key | Env Var | Default | Description |
|---|---|---|---|
cookieHttpOnly | SESSION_COOKIE_HTTP_ONLY | true | Prevents JavaScript access to the cookie |
cookieSecure | SESSION_COOKIE_SECURE | false | Only send cookie over HTTPS |
cookieSameSite | SESSION_COOKIE_SAME_SITE | "Strict" | CSRF protection (Strict, Lax, or None) |
For production, set SESSION_COOKIE_SECURE=true so cookies are only transmitted over HTTPS. The SameSite=Strict default prevents CSRF attacks by ensuring cookies aren't sent on cross-origin requests.
CORS
Cross-origin request handling is configured on the web server:
| Config Key | Env Var | Default |
|---|---|---|
allowedOrigins | WEB_SERVER_ALLOWED_ORIGINS | "*" |
allowedMethods | WEB_SERVER_ALLOWED_METHODS | "HEAD, GET, POST, PUT, PATCH, DELETE, OPTIONS" |
allowedHeaders | WEB_SERVER_ALLOWED_HEADERS | "Content-Type" |
Important: When allowedOrigins is "*" (the default), the server will not send Access-Control-Allow-Credentials: true — this follows the browser spec that forbids wildcard origins with credentials. For production, set WEB_SERVER_ALLOWED_ORIGINS to your specific domain(s) so that credentialed requests (cookies, auth headers) work correctly.
Request Body Size Limits
HTTP request bodies are limited to prevent memory exhaustion from oversized payloads. The limit is enforced in two layers:
- Pre-flight check — if the request includes a
Content-Lengthheader larger than the limit, the server rejects it immediately with413 Payload Too Largebefore reading any body data - Read-time check — when parsing the body (JSON, form data, or multipart), the raw byte size is verified before parsing begins, catching chunked requests that omit
Content-Length
| Config Key | Env Var | Default | Description |
|---|---|---|---|
maxBodySize | WEB_MAX_BODY_SIZE | 10485760 (10 MB) | Max request body size in bytes |
Set WEB_MAX_BODY_SIZE=0 to disable the limit entirely (not recommended for production).
The WebSocket transport has its own payload limit via websocket.maxPayloadSize (default 64 KB) — see WebSocket Protections below.
WebSocket Protections
WebSocket connections have several layers of protection:
Origin Validation
Before upgrading an HTTP connection to WebSocket, the server validates the Origin header against config.server.web.allowedOrigins. If the origin doesn't match, the upgrade is rejected. This prevents Cross-Site WebSocket Hijacking (CSWSH) attacks.
Message Limits
| Config Key | Env Var | Default | Description |
|---|---|---|---|
websocket.maxPayloadSize | WS_MAX_PAYLOAD_SIZE | 65536 | Max message size in bytes (64 KB) |
websocket.maxMessagesPerSecond | WS_MAX_MESSAGES_PER_SECOND | 20 | Per-connection rate limit |
websocket.maxSubscriptions | WS_MAX_SUBSCRIPTIONS | 100 | Max channel subscriptions per conn |
Messages exceeding the payload size are rejected. Clients sending more than the per-second limit are disconnected. These protect against resource exhaustion from misbehaving or malicious clients.
Channel Validation
- Channel names must match the pattern
/^[a-zA-Z0-9:._-]{1,200}$/— alphanumeric characters plus:,.,_,-, max 200 characters - Undefined channels are rejected — if no registered channel matches the requested name, the subscription is denied with a
CONNECTION_CHANNEL_AUTHORIZATIONerror
OAuth Security
The MCP server's OAuth 2.1 implementation includes several hardening measures:
Redirect URI Validation
When clients register via /oauth/register, redirect URIs are validated:
- Must be a valid URL
- Must not contain a fragment (
#) - Must not contain userinfo (username/password in the URL)
- Must use HTTPS for non-localhost URIs
When authorizing or exchanging authorization codes, the redirect URI must match a registered URI using exact string comparison (per RFC 6749 §3.1.2.3).
Registration Rate Limiting
OAuth client registration (POST /oauth/register) has a separate, stricter rate limit to prevent abuse:
| Config Key | Env Var | Default | Description |
|---|---|---|---|
oauthRegisterLimit | RATE_LIMIT_OAUTH_REGISTER_LIMIT | 5 | Max registrations per window |
oauthRegisterWindowMs | RATE_LIMIT_OAUTH_REGISTER_WINDOW_MS | 3600000 | Window size (1 hour) |
Error Stack Traces
By default, error responses include stack traces in development but omit them in production:
| Config Key | Env Var | Default |
|---|---|---|
| Web server | WEB_SERVER_INCLUDE_STACK_IN_ERRORS | true (dev), false (prod) |
| CLI | CLI_INCLUDE_STACK_IN_ERRORS | true |
The web server default is based on NODE_ENV — when NODE_ENV=production, stack traces are automatically hidden from HTTP responses to avoid leaking internal implementation details.
If the server boots with includeStackInErrors=true and binds to a non-localhost address, a warning is logged at startup. Stack traces in error responses leak deployment paths, package structure, and file/line numbers. To suppress them on a non-production host that is publicly reachable, set WEB_SERVER_INCLUDE_STACK_IN_ERRORS=false (or NODE_ENV=production).
Reverse Proxy & Forwarded Headers
The server can derive its external-facing origin (used in OAuth metadata, MCP WWW-Authenticate URLs, and protected-resource metadata) from X-Forwarded-Proto and X-Forwarded-Host headers. Because any client can spoof these headers when the server is reachable directly, trusting them unconditionally would let an attacker poison OAuth discovery responses and redirect MCP clients to attacker-controlled hosts.
By default, oauthTrustProxy is false and forwarded headers are ignored — origin resolution falls back to applicationUrl (when set) or the request URL. Enable it only when the server is behind a reverse proxy that strips client-supplied X-Forwarded-* headers and sets them itself.
| Key | Env Var | Default | Description |
|---|---|---|---|
oauthTrustProxy | MCP_OAUTH_TRUST_PROXY | false | Honor X-Forwarded-Proto / X-Forwarded-Host (and Host). |
For most production deployments, set APPLICATION_URL to your canonical external URL — it takes precedence over any forwarded headers and is the safest configuration regardless of oauthTrustProxy.
Correlation IDs
When a reverse proxy or load balancer sets a correlation ID header (e.g. X-Request-Id), the server can propagate it through the stack for distributed tracing. Enable this by setting trustProxy to true — the server will read the configured header from the incoming request and echo it back in the response. If the header is not present on a request, no correlation ID is set.
| Config Key | Env Var | Default | Description |
|---|---|---|---|
header | WEB_CORRELATION_ID_HEADER | "X-Request-Id" | Header name to read/echo (empty string to disable) |
trustProxy | WEB_CORRELATION_ID_TRUST_PROXY | false | Read and echo the incoming correlation ID header from proxies |
Correlation IDs appear in action log lines as [cor:<id>]:
[ACTION:WEB:OK] status (3ms) [GET] 127.0.0.1(http://localhost:8080/api/status) [cor:a1b2c3d4-...] {}For fan-out tasks, you can propagate the parent's correlation ID to child jobs via correlationId in the fan-out options:
const result = await api.actions.fanOut("child:action", inputsArray, "worker", {
correlationId: connection.correlationId,
});Static File Path Traversal
Static file serving validates requested paths to prevent directory traversal attacks. Requests containing .. segments that would escape the configured static files directory are rejected with a 403.
Production Checklist
When deploying to production, review these environment variables:
# Cookie security — require HTTPS
SESSION_COOKIE_SECURE=true
# External origin — set to your canonical URL (used for OAuth/MCP metadata)
APPLICATION_URL=https://yourapp.com
# Trust proxy — only enable behind a reverse proxy that strips client X-Forwarded-* headers
# MCP_OAUTH_TRUST_PROXY=true
# CORS — restrict to your domain
WEB_SERVER_ALLOWED_ORIGINS=https://yourapp.com
# Request body size — tune for your payload needs (default 10 MB)
WEB_MAX_BODY_SIZE=10485760
# Rate limiting — tune for your traffic
RATE_LIMIT_ENABLED=true
RATE_LIMIT_UNAUTH_LIMIT=20
RATE_LIMIT_AUTH_LIMIT=200
# Error responses — hide internals
NODE_ENV=production
# (stack traces auto-disabled when NODE_ENV=production)
# Security headers — defaults are good, customize CSP if needed
WEB_SECURITY_CSP="default-src 'self'; script-src 'self'"