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 "../middleware/rateLimit";
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 "../middleware/rateLimit";
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' |
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 may need loosening if your backend serves HTML with inline scripts or external resources — adjust via WEB_SECURITY_CSP.
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.
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 |
|---|---|---|---|
websocketMaxPayloadSize | WS_MAX_PAYLOAD_SIZE | 65536 | Max message size in bytes (64 KB) |
websocketMaxMessagesPerSecond | WS_MAX_MESSAGES_PER_SECOND | 20 | Per-connection rate limit |
websocketMaxSubscriptions | 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
CHANNEL_NOT_FOUNDerror
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 exchanging authorization codes, the redirect URI must match the registered URI exactly (origin + pathname comparison).
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.
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
# CORS — restrict to your domain
WEB_SERVER_ALLOWED_ORIGINS=https://yourapp.com
# 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'"