Skip to content

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:

ts
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 KeyEnv VarDefaultDescription
enabledRATE_LIMIT_ENABLEDtrueMaster toggle (disabled in test)
windowMsRATE_LIMIT_WINDOW_MS60000Sliding window size (ms)
unauthenticatedLimitRATE_LIMIT_UNAUTH_LIMIT20Max requests per window (no session)
authenticatedLimitRATE_LIMIT_AUTH_LIMIT200Max requests per window (logged in)
keyPrefixRATE_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:

ts
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:

HeaderEnv VarDefault
Content-Security-PolicyWEB_SECURITY_CSPdefault-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-OptionsWEB_SECURITY_CONTENT_TYPE_OPTIONSnosniff
X-Frame-OptionsWEB_SECURITY_FRAME_OPTIONSDENY
Strict-Transport-SecurityWEB_SECURITY_HSTSmax-age=31536000; includeSubDomains
Referrer-PolicyWEB_SECURITY_REFERRER_POLICYstrict-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.

Session cookies are configured with security flags:

Config KeyEnv VarDefaultDescription
cookieHttpOnlySESSION_COOKIE_HTTP_ONLYtruePrevents JavaScript access to the cookie
cookieSecureSESSION_COOKIE_SECUREfalseOnly send cookie over HTTPS
cookieSameSiteSESSION_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 KeyEnv VarDefault
allowedOriginsWEB_SERVER_ALLOWED_ORIGINS"*"
allowedMethodsWEB_SERVER_ALLOWED_METHODS"HEAD, GET, POST, PUT, PATCH, DELETE, OPTIONS"
allowedHeadersWEB_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:

  1. Pre-flight check — if the request includes a Content-Length header larger than the limit, the server rejects it immediately with 413 Payload Too Large before reading any body data
  2. 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 KeyEnv VarDefaultDescription
maxBodySizeWEB_MAX_BODY_SIZE10485760 (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 KeyEnv VarDefaultDescription
websocket.maxPayloadSizeWS_MAX_PAYLOAD_SIZE65536Max message size in bytes (64 KB)
websocket.maxMessagesPerSecondWS_MAX_MESSAGES_PER_SECOND20Per-connection rate limit
websocket.maxSubscriptionsWS_MAX_SUBSCRIPTIONS100Max 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_AUTHORIZATION error

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 KeyEnv VarDefaultDescription
oauthRegisterLimitRATE_LIMIT_OAUTH_REGISTER_LIMIT5Max registrations per window
oauthRegisterWindowMsRATE_LIMIT_OAUTH_REGISTER_WINDOW_MS3600000Window size (1 hour)

Error Stack Traces

By default, error responses include stack traces in development but omit them in production:

Config KeyEnv VarDefault
Web serverWEB_SERVER_INCLUDE_STACK_IN_ERRORStrue (dev), false (prod)
CLICLI_INCLUDE_STACK_IN_ERRORStrue

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.

KeyEnv VarDefaultDescription
oauthTrustProxyMCP_OAUTH_TRUST_PROXYfalseHonor 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 KeyEnv VarDefaultDescription
headerWEB_CORRELATION_ID_HEADER"X-Request-Id"Header name to read/echo (empty string to disable)
trustProxyWEB_CORRELATION_ID_TRUST_PROXYfalseRead 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:

ts
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:

bash
# 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'"

Released under the MIT License.