@derian-cordoba/api-gateway
v2.0.0
Published
A generic, configuration-driven HTTP API gateway with per-route rate limiting, structured logging, and security headers
Maintainers
Readme
API Gateway
A generic, configuration-driven HTTP API gateway. Routes incoming requests to upstream services via a JSON config file or environment variable, with per-route load balancing, rate limiting, authentication, circuit breaking, IP filtering, request ID propagation, request timeouts, WebSocket proxying, retry with backoff, response caching, Prometheus metrics, header transformation, route-level CORS, OAuth 2.0 token introspection, structured logging, and full security headers out of the box.
Table of Contents
- Features
- Requirements
- Getting Started
- Dashboard
- Configuration
- Authentication
- Request Validation
- Webhook Verification
- Upstream Request Signing
- Traffic Mirroring
- Request Timeout per Route
- Circuit Breaker
- Load Balancing
- WebSocket Proxying
- Request ID Propagation
- IP Allowlist / Blocklist
- Retry with Backoff
- Response Caching
- Prometheus Metrics
- Header Transformation
- Route-Level CORS Override
- Hot Config Reload
- Running the Gateway
- Health Check
- Project Structure
- Example Projects
- Architecture
- Guides
Features
- Configuration-driven routing — define proxy routes in a JSON file, an environment variable, or both; changes take effect on restart with zero code changes
- Per-route authentication — protect any route with a JWT Bearer token (HMAC or RSA/EC), an API key, or HTTP Basic Auth; set
enabled: falseto bypass with zero overhead - Per-route rate limiting — each route can declare its own
maxrequests /windowMswindow, enforced byexpress-rate-limit - Per-route circuit breaker — automatically stops forwarding to a failing upstream after a configurable failure threshold, returning
503until the service recovers; prevents cascading failures across your stack - Request ID propagation — every request receives a
X-Request-IDheader (generated UUID v4 if absent, forwarded unchanged if already set); the same ID appears in the response header, every gateway log line, and the request forwarded to the upstream — enabling end-to-end request tracing with no external infrastructure - IP allowlist / blocklist — per-route IPv4 and CIDR-range filtering; deny list is evaluated first, allow list restricts access to specified addresses only; IPv4-mapped IPv6 addresses are normalised automatically
- Load balancing — distribute traffic across multiple upstream targets with four strategies:
round-robin(default),weighted(proportional weight per target),least-connections(always forwards to the least-busy upstream), andsticky(session-affinity — routes a given client to the same upstream on every request); fully composable with auth, rate limiting, and the circuit breaker - Per-route request timeout — set
proxy.timeouton any route to cap how long the gateway waits for an upstream response; slow upstreams receive a504 Gateway Timeoutand the upstream connection is aborted - WebSocket proxying — enable
ws: trueon any route to proxy WebSocket upgrade requests transparently; all subsequent frames are tunnelled to the upstream without additional configuration - Startup validation — route config is validated with Zod at boot time; the process exits with a descriptive error rather than silently misbehaving
- Structured logging —
pino+pino-httpemit newline-delimited JSON in production and human-readable output (viapino-pretty) in development - Security headers — full
helmetdefaults applied to every response (CSP,HSTS,X-Frame-Options,X-Content-Type-Options, etc.) - Configurable CORS — origins, methods, and allowed headers controlled via environment variables
- Health check endpoint —
GET /healthreturns uptime, version, and timestamp; always available regardless of configured routes - Optional URL prefix — mount all routes under a shared prefix (e.g.
/api/v1) viaGATEWAY_PREFIX - Body forwarding — JSON bodies on
POST,PUT, andPATCHrequests are correctly forwarded to upstreams (fixRequestBody) - Retry with backoff — configurable retries for HTTP failures and network errors; the route backend currently uses fixed delays, while exponential and jitter strategy classes are available for custom executor composition
- In-memory response caching — cache upstream responses per route with a configurable TTL; cache hits bypass the upstream entirely and return the stored response with an
X-Cache: HITheader; configurable by HTTP method and status code - Prometheus metrics endpoint —
GET /metricsexposesgateway_requests_total,gateway_request_duration_seconds,gateway_upstream_errors_total, andgateway_cache_hits_totalin Prometheus text format; labelled by route and method for easy dashboarding - Per-route header transformation — add, override, or remove individual headers on the outgoing upstream request and/or the response returned to the client; no code changes needed when onboarding a new upstream with different header conventions
- Route-level CORS override — each route can declare its own CORS policy (origin, methods, allowed headers, credentials, preflight
maxAge) that takes precedence over the global configuration; preflightOPTIONSrequests are handled entirely by the gateway for routes that have a cors block - OAuth 2.0 token introspection — fourth auth strategy that validates opaque Bearer tokens by calling an RFC 7662 introspection endpoint; the gateway authenticates to the introspection endpoint using HTTP Basic auth with configurable
clientId/clientSecret - Hot config reload — edit the routes JSON file (or send
SIGHUP) and the gateway picks up the new routing table immediately, with no process restart and no dropped connections; built-in 300 ms debounce prevents churn on rapid saves - Visual configuration dashboard — manage every route feature through a Next.js UI backed by revision-safe, atomic updates to the local JSON route file
- Graceful shutdown —
SIGINTanduncaughtExceptionhandlers stop the server cleanly before exiting - Request validation — require JSON fields, restrict content types, and reject declared body lengths above a per-route limit before forwarding
- Webhook verification — GitHub, Stripe, and custom HMAC signature verification using separate provider strategies and typed configuration
- Upstream request signing — sign forwarded bodies with HMAC-SHA256 through the retry backend
- Traffic mirroring — copy a configurable percentage of primary traffic to a shadow target through the retry backend
- Extended authentication — JWKS public-key lookup, forwarding verified JWT claims to headers, OAuth introspection caching, and per-IP authentication failure limits
- Failure handling — configurable circuit-open and retry-error responses, method-aware HTTP-status retries, and optional in-flight request collapsing
- Runnable examples — 20 standalone projects with shared HTTP helpers, centralized startup and cleanup, local JavaScript services, and HTTP smoke checks
Requirements
- Node.js 20.9+
- pnpm 10+
Getting Started
# 1. Clone and install
git clone <repo-url>
cd api-gateway
pnpm install
# 2. Create your env file
cp .env.example .env
# 3. Create a routes config (see Route Configuration below)
cp examples/basic/routes.json routes.json # or write your own
# 4. Start in development mode (hot-reload)
pnpm devDashboard
The Next.js dashboard lives entirely in src/apps/dashboard. It provides visual editors for proxy targets, load balancing and mirroring, upstream signing, request validation, webhook verification, authentication, rate limiting, circuit breaking and fallbacks, retries, caching, IPv4/IPv6 filtering, header transforms, and route-level CORS.
Run the gateway and dashboard in separate terminals:
pnpm dev:gateway
pnpm dev:dashboardThe dashboard is available at http://localhost:3001 and uses the same routes.json file as the gateway. Copy the dashboard environment example when you need a different file path or access token:
cp src/apps/dashboard/.env.example src/apps/dashboard/.env.localDashboard writes are validated by the gateway's Zod schemas, guarded by a configuration revision, written through a temporary file, and atomically renamed. The gateway watches the containing directory so these atomic updates activate without restarting either application.
Set DASHBOARD_TOKEN outside local development. The browser token can then be entered on the dashboard Settings page; it is stored only in that browser.
Dashboard commands:
pnpm dev:dashboard
pnpm build:dashboard
pnpm lint:dashboard
pnpm lint:dashboard:fix
pnpm --dir src/apps/dashboard test
pnpm test:dashboard-apitest:dashboard-api builds the dashboard, starts it on port 3101 with an isolated temporary copy of the mock route fixture, and exercises every dashboard API with curl. Override the port with DASHBOARD_TEST_PORT if needed. The test never reads or writes the project's real routes.json.
Configuration
Environment Variables
Copy .env.example to .env and edit as needed.
Server
| Variable | Default | Description |
|---|---|---|
| GATEWAY_PORT | 3000 | Port the gateway listens on. Takes priority over PORT. |
| PORT | 3000 | Fallback port when GATEWAY_PORT is not set. |
| GATEWAY_PREFIX | (none) | Optional path prefix for all routes. Example: /api/v1 makes proxy routes reachable at /api/v1/<baseURL> and the health check at /api/v1/health. |
Logging
| Variable | Default | Description |
|---|---|---|
| NODE_ENV | development | Set to production to disable pino-pretty and emit newline-delimited JSON. |
| LOG_LEVEL | info | Pino log level: trace · debug · info · warn · error · fatal. |
CORS
| Variable | Default | Description |
|---|---|---|
| CORS_ORIGINS | * | Comma-separated list of allowed origins. Use * to allow all. |
| CORS_METHODS | GET,POST,PUT,DELETE,PATCH,OPTIONS | Comma-separated list of allowed HTTP methods. |
| CORS_HEADERS | Content-Type,Authorization | Comma-separated list of allowed request headers. |
Routes
| Variable | Default | Description |
|---|---|---|
| ROUTES_FILE_PATH | routes.json | Path to the JSON route config file, relative to process.cwd(). |
| ROUTES | (none) | Inline route definitions as a JSON array. Merged with ROUTES_FILE_PATH. Useful for containerised deployments where injecting a file is inconvenient. |
Authentication
| Variable | Default | Description |
|---|---|---|
| JWT_SECRET | (none) | Fallback HMAC signing secret used when a JWT route has no inline secret field. |
| JWT_PUBLIC_KEY | (none) | Fallback PEM public key used when a JWT route has no inline publicKey field. Takes precedence over JWT_SECRET. |
Tunable proxy defaults
These variables override hardcoded defaults without requiring route-level configuration changes.
| Variable | Default | Description |
|---|---|---|
| METRICS_HISTOGRAM_BUCKETS | 0.005,0.01,...,10 | Comma-separated histogram bucket boundaries (seconds) for gateway_request_duration_seconds. |
| ROUTES_DEBOUNCE_MS | 300 | Milliseconds to debounce file-watcher events before reloading routes. |
| RETRY_BACKOFF_MULTIPLIER | 2 | Base multiplier for exponential backoff (delay × multiplier^attempt). |
| CIRCUIT_BREAKER_SUCCESS_THRESHOLD | 1 | Default successThreshold applied when not set in a route's circuitBreaker block. |
| CACHE_DEFAULT_METHODS | GET,HEAD | Comma-separated HTTP methods cached when a route's cache block omits methods. |
| CACHE_DEFAULT_STATUS_CODES | 200,203,204 | Comma-separated status codes cached when a route's cache block omits statusCodes. |
Route Configuration
Routes are defined as a JSON array. Each entry is a Gateway object:
{
baseURL: string // required — path prefix to match, must start with "/"
proxy: Proxy // required — upstream proxy settings (single target or load-balanced targets)
rateLimit?: RateLimit // optional — per-route rate limiting
auth?: Auth // optional — per-route authentication (JWT, API key, Basic, OAuth2)
circuitBreaker?: CircuitBreaker // optional — per-route circuit breaker
ipFilter?: IpFilter // optional — per-route IP allowlist / blocklist
retry?: Retry // optional — per-route retry with backoff
cache?: Cache // optional — per-route in-memory response caching
headers?: Headers // optional — per-route request / response header transforms
cors?: RouteCors // optional — per-route CORS policy (overrides global)
validation?: ValidationConfig // optional — body fields, content type, and declared size
webhook?: WebhookConfig // optional — inbound provider signature verification
}Proxy
Exactly one of target or targets must be provided.
| Field | Type | Required | Description |
|---|---|---|---|
| target | string | ✅ (or targets) | Single upstream URL. Mutually exclusive with targets. |
| targets | WeightedTarget[] | ✅ (or target) | Two or more upstream URLs for load balancing. Mutually exclusive with target. |
| strategy | "round-robin" \| "weighted" \| "least-connections" \| "sticky" | — | Load-balancing strategy. Only valid with targets. Defaults to "round-robin". |
| stickyKey | string | — | Required for "sticky"; accepts "ip", "header:<name>", "jwt:<claim>", "cookie:<name>", or "query:<name>". There is no schema default. |
| ws | boolean | — | Enable WebSocket proxying for this route. |
| changeOrigin | boolean | — | Rewrite the Host header to the target origin. |
| pathRewrite | { [pattern]: replacement } | — | Regex path rewrite rules applied before forwarding. |
| headers | { [name]: value } | — | Extra headers added to every forwarded request. |
| isSecure | boolean | — | Verify the upstream TLS certificate. |
| method | string | — | Override the HTTP method forwarded to the upstream. |
| timeout | number | — | Maximum milliseconds to wait for an upstream response. Exceeding this limit returns 504 Gateway Timeout and aborts the upstream connection. |
| upstreamAuth | { type: "hmac-sha256"; secret: string; header?: string } | — | Sign the forwarded body; default header is x-gateway-signature. Requires the retry backend. See Upstream Request Signing. |
| mirror | { target: string; percentage?: number } | — | Shadow target URL and sampling percentage (0–100, default 100). Requires the retry backend. See Traffic Mirroring. |
WeightedTarget
| Field | Type | Required | Description |
|---|---|---|---|
| url | string | ✅ | Upstream URL for this target. |
| weight | number | — | Relative weight for the "weighted" strategy. Higher values receive proportionally more traffic. Defaults to 1. Ignored by other strategies. |
RateLimit
| Field | Type | Required | Description |
|---|---|---|---|
| max | number | ✅ | Maximum number of requests allowed per window. |
| windowMs | number | ✅ | Time window in milliseconds. |
| statusCode | number (400–599) | — | HTTP status returned when the limit is exceeded (default: 429). |
| message | string | — | Response message when the limit is exceeded (default: "Too many requests"). |
| keyBy | string | — | Key-derivation strategy for rate-limit bucketing (see table below). Defaults to client IP. |
keyBy values
| Value | Description |
|---|---|
| "ip" | Client IP address (default). |
| "query:<name>" | String query parameter (e.g. "query:tenant"); falls back to IP when unavailable. |
| "header:<name>" | Value of the named request header (e.g. "header:X-API-Key"). |
| "jwt:<claim>" | Claim extracted from the decoded JWT payload (e.g. "jwt:sub"). Falls back to IP when the token or claim is absent. |
| "cookie:<name>" | Value of the named cookie (e.g. "cookie:session_id"). Requires cookie-parser to be mounted. Falls back to IP when the cookie is absent. |
Responses include standard RateLimit-* headers (RFC draft-8).
Auth
Adds authentication middleware to a route. When enabled is false the middleware is a no-op passthrough — no overhead, no token check.
Four strategies are supported: jwt, apiKey, basicAuth, and oauth2. All support optional authRateLimit: { max, windowMs }; see Authentication Failure Limiting.
"jwt" — Bearer token validation
| Field | Type | Required | Description |
|---|---|---|---|
| enabled | boolean | ✅ | true to enforce, false to bypass. |
| strategy | "jwt" | ✅ | — |
| secret | string | — | Shared secret for HMAC algorithms (HS256, HS384, HS512). Falls back to JWT_SECRET env var. |
| publicKey | string | — | PEM-encoded public key or X.509 certificate for asymmetric algorithms (RS256, RS384, RS512, ES256 …). Falls back to JWT_PUBLIC_KEY env var. Takes precedence over secret when both are present. |
| algorithms | string[] | — | Explicit algorithm allowlist. Defaults to ["RS256"] when publicKey is used, ["HS256"] otherwise. Recommended to prevent algorithm-confusion attacks. |
| jwksUri | string | — | JWKS endpoint for public-key lookup using the token header's kid. Takes precedence over static keys; asymmetric default algorithm is RS256. |
| forwardClaims | Record<string, string> | — | Map verified claim names to outgoing header names, e.g. { "sub": "X-User-ID" }. Values are converted to strings. |
For enabled JWT routes loaded from JSON, the schema requires at least one inline secret, publicKey, or jwksUri. Runtime environment fallbacks alone do not satisfy this validation rule.
"apiKey" — Header-based API key
| Field | Type | Required | Description |
|---|---|---|---|
| enabled | boolean | ✅ | true to enforce, false to bypass. |
| strategy | "apiKey" | ✅ | — |
| keys | string[] | ✅ | List of valid API keys. At least one entry required. |
| header | string | — | Header name to read the key from (default: x-api-key). |
"basicAuth" — HTTP Basic Authentication
| Field | Type | Required | Description |
|---|---|---|---|
| enabled | boolean | ✅ | true to enforce, false to bypass. |
| strategy | "basicAuth" | ✅ | — |
| credentials | { username: string; password: string }[] | ✅ | List of valid username/password pairs. At least one entry required. Credentials are compared using a timing-safe algorithm. |
| realm | string | — | Value for the WWW-Authenticate response header (default: "API Gateway"). |
CircuitBreaker
Stops forwarding requests to a failing upstream after a configurable number of consecutive failures and returns 503 Service Unavailable until the upstream recovers. See Circuit Breaker for a full explanation.
| Field | Type | Required | Description |
|---|---|---|---|
| threshold | number | ✅ | Consecutive failures before the circuit opens. Must be a positive integer. |
| timeout | number | ✅ | Milliseconds the circuit stays open before transitioning to half-open and sending a probe request. |
| successThreshold | number | — | Consecutive probe successes required to close the circuit (default: 1). |
| healthCheck | HealthCheck | — | Active health-check probe that pings a URL while the circuit is open to accelerate recovery. |
| fallback | { status?: number; body?: unknown; headers?: Record<string, string> } | — | Response when the circuit rejects a request. Status defaults to 503; an omitted body produces an empty response. Retry-After is still set unless overridden by fallback headers. |
HealthCheck
| Field | Type | Required | Description |
|---|---|---|---|
| url | string | ✅ | URL to probe with GET. A 2xx response counts as a success. |
| intervalMs | number | ✅ | Milliseconds between probes. |
| timeoutMs | number | — | Timeout for each probe request in milliseconds (default: 5000). |
IpFilter
Restricts access to a route based on the client's IP address. At least one of allow or deny must be provided. See IP Allowlist / Blocklist for a full explanation.
| Field | Type | Required | Description |
|---|---|---|---|
| allow | string[] | — | IPv4 addresses or CIDR ranges that are explicitly allowed. When set, only listed IPs can access the route. At least one entry required. |
| deny | string[] | — | IPv4 addresses or CIDR ranges that are explicitly blocked. Evaluated before allow — a match returns 403 immediately. At least one entry required. |
Both fields accept plain IPv4 addresses (192.168.1.1) and CIDR notation (10.0.0.0/8). IPv4-mapped IPv6 addresses (::ffff:192.168.1.1) are normalised to their IPv4 form before matching, so you never need to list both forms.
Native IPv6 addresses and CIDRs are also supported, such as ::1 and 2001:db8::/32; allow and deny lists can contain both address families.
Retry
Automatically retries failed upstream requests (5xx responses or network errors) before returning a failure to the client. The upstream is called up to attempts + 1 times total. See Retry with Backoff for a full explanation.
| Field | Type | Required | Description |
|---|---|---|---|
| attempts | number (1–10) | ✅ | Maximum number of retry attempts after the first failure. |
| delay | number | ✅ | Base delay in milliseconds between retries. |
| backoff | "fixed" \| "exponential" \| "exponential-jitter" | — | Accepted strategy names; see the current backend limitation under Backoff strategies. |
| retryOn | number[] | — | Explicit list of HTTP status codes that should trigger a retry (e.g. [500, 502, 503]). When omitted, all 5xx responses are retried. Each code must be in the 400–599 range. |
| retryMethods | string[] | — | Methods eligible for HTTP-status retries; defaults to GET, HEAD, and OPTIONS. Network errors currently retry independently of this list. |
| fallback | { status?: number; body?: unknown } | — | Response for thrown proxy/retry errors. Status defaults to 502. Does not replace a final HTTP error response returned by the upstream. |
| collapseRequests | boolean | — | Share concurrent GET, HEAD, or OPTIONS work by method and URL. Defaults to false. Headers and caller identity are not part of the key. |
Cache
Caches successful upstream responses in memory per route. Cache hits bypass the upstream entirely. See Response Caching for a full explanation.
| Field | Type | Required | Description |
|---|---|---|---|
| ttl | number | ✅ | Time-to-live in milliseconds. |
| methods | string[] | — | HTTP methods to cache. Defaults to ["GET", "HEAD"]. |
| statusCodes | number[] | — | HTTP status codes to cache. Defaults to [200, 203, 204]. |
Headers
Transforms request headers before forwarding to the upstream and/or response headers before returning to the client. See Header Transformation for a full explanation.
{
request?: {
set?: Record<string, string> // add or override headers sent to the upstream
remove?: string[] // remove headers before forwarding
}
response?: {
set?: Record<string, string> // add or override headers returned to the client
remove?: string[] // remove headers before returning to the client
}
}At least one of set or remove must be present inside each transform block; at least one of request or response must be present in the headers object.
RouteCors
Overrides the global CORS policy for a specific route, including preflight OPTIONS handling. Routes without a cors block inherit the global config and forward OPTIONS to the upstream. See Route-Level CORS Override for a full explanation.
| Field | Type | Required | Description |
|---|---|---|---|
| origin | string \| string[] \| boolean | ✅ | Allowed origins. A single domain string, an array of domains, true to reflect the request Origin header, or false to disable CORS for this route. |
| methods | string[] | — | Allowed HTTP methods. Defaults to the global CORS_METHODS config. |
| allowedHeaders | string[] | — | Allowed request headers. Defaults to the global CORS_HEADERS config. |
| credentials | boolean | — | Whether to allow credentials (cookies, Authorization header). When true, origin must not be "*". |
| maxAge | number | — | Seconds the browser may cache the preflight response (Access-Control-Max-Age). |
Example routes.json
[
{
"baseURL": "/users",
"proxy": {
"target": "http://users-service:3001",
"changeOrigin": true,
"pathRewrite": { "^/users": "" }
},
"rateLimit": {
"max": 100,
"windowMs": 60000,
"statusCode": 429,
"message": "Too many requests. Please try again in a minute."
}
},
{
"baseURL": "/orders",
"proxy": {
"target": "http://orders-service:3002",
"changeOrigin": true,
"pathRewrite": { "^/orders": "" }
},
"auth": {
"enabled": true,
"strategy": "jwt",
"secret": "example-signing-secret"
}
},
{
"baseURL": "/reports",
"proxy": {
"target": "http://reports-service:3003",
"changeOrigin": true,
"pathRewrite": { "^/reports": "" }
},
"auth": {
"enabled": true,
"strategy": "apiKey",
"keys": ["key-service-alpha-123", "key-service-beta-456"]
}
},
{
"baseURL": "/payments",
"proxy": {
"target": "http://payments-service:3004",
"changeOrigin": true,
"pathRewrite": { "^/payments": "" }
},
"circuitBreaker": {
"threshold": 5,
"timeout": 30000,
"successThreshold": 1
}
},
{
"baseURL": "/internal/metrics",
"proxy": {
"target": "http://metrics-service:3005",
"changeOrigin": true,
"pathRewrite": { "^/internal/metrics": "" }
},
"ipFilter": {
"allow": ["10.0.0.0/8", "172.16.0.0/12"]
}
},
{
"baseURL": "/catalog",
"proxy": {
"targets": [
{ "url": "http://catalog-a:3006", "weight": 1 },
{ "url": "http://catalog-b:3007", "weight": 2 }
],
"strategy": "weighted",
"changeOrigin": true,
"pathRewrite": { "^/catalog": "" }
}
},
{
"baseURL": "/realtime",
"proxy": {
"target": "http://ws-service:3008",
"changeOrigin": true,
"ws": true
}
},
{
"baseURL": "/admin",
"proxy": {
"target": "http://admin-service:3009",
"changeOrigin": true,
"pathRewrite": { "^/admin": "" }
},
"auth": {
"enabled": true,
"strategy": "basicAuth",
"credentials": [
{ "username": "alice", "password": "s3cr3t" }
],
"realm": "Admin Panel"
}
},
{
"baseURL": "/external",
"proxy": {
"target": "http://third-party-api:443",
"changeOrigin": true,
"pathRewrite": { "^/external": "" },
"timeout": 5000
}
}
]Config is validated with Zod at startup. If any route is invalid the process exits immediately with a detailed per-field error message.
Routes are loaded from two sources at startup and merged:
ROUTES_FILE_PATH— JSON file on disk (missing file is a warning, not an error)ROUTES— JSON array in an environment variable
Authentication
Authentication is optional and configured per route via the auth field. The middleware is applied before rate limiting and proxying. When enabled: false the handler is a single no-op function — zero overhead on unprotected routes.
JWT
Protect a route with a Bearer token. The gateway validates the token signature; your upstream receives the request only if verification passes.
{
"baseURL": "/orders",
"proxy": { "target": "http://orders-service:3002", "changeOrigin": true },
"auth": {
"enabled": true,
"strategy": "jwt",
"secret": "super-secret-key-change-in-production"
}
}When jwksUri is configured, keys are resolved from that endpoint using the token's kid. Otherwise, the signing key is resolved in this order:
publicKeyfield in the route config (PEM — use for RS256 / ES256)JWT_PUBLIC_KEYenvironment variablesecretfield in the route config (string — use for HS256)JWT_SECRETenvironment variable
publicKey takes precedence over secret for static-key verification. Enabled JSON routes must explicitly include secret, publicKey, or jwksUri to pass startup validation; environment variables alone are insufficient.
HMAC (HS256) — shared secret:
# .env
JWT_SECRET=super-secret-key-change-in-productionTOKEN=$(curl -s -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"password123"}' | jq -r '.token')
curl http://localhost:3000/orders \
-H "Authorization: Bearer $TOKEN"RSA (RS256) — public/private key pair:
{
"auth": {
"enabled": true,
"strategy": "jwt",
"publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjAN...\n-----END PUBLIC KEY-----",
"algorithms": ["RS256"]
}
}API Key
Protect a route with a pre-shared key delivered in a request header.
{
"baseURL": "/reports",
"proxy": { "target": "http://reports-service:3003", "changeOrigin": true },
"auth": {
"enabled": true,
"strategy": "apiKey",
"header": "x-api-key",
"keys": ["key-service-alpha-123", "key-service-beta-456"]
}
}# Valid key → 200
curl http://localhost:3000/reports \
-H "x-api-key: key-service-alpha-123"
# Missing or wrong key → 401
curl http://localhost:3000/reportsMultiple keys in keys let you rotate credentials without downtime — add the new key, deploy, then remove the old one.
Basic Auth
Protect a route with HTTP Basic Authentication. The gateway decodes the Authorization: Basic <base64> header, checks the username/password pair against the configured list, and returns 401 with a WWW-Authenticate header if the credentials are missing or wrong. Credential comparison is timing-safe to prevent enumeration attacks.
{
"baseURL": "/admin",
"proxy": { "target": "http://admin-service:3010", "changeOrigin": true },
"auth": {
"enabled": true,
"strategy": "basicAuth",
"credentials": [
{ "username": "alice", "password": "s3cr3t" },
{ "username": "deploy-bot", "password": "ci-token-xyz" }
],
"realm": "Admin Panel"
}
}# Valid credentials → 200
curl http://localhost:3000/admin \
-u alice:s3cr3t
# Wrong password → 401 + WWW-Authenticate header
curl -si http://localhost:3000/admin \
-u alice:wrongpassword | head -3
# HTTP/1.1 401 Unauthorized
# WWW-Authenticate: Basic realm="Admin Panel"
# No credentials → 401
curl -si http://localhost:3000/admin | head -3
# HTTP/1.1 401 Unauthorized
# WWW-Authenticate: Basic realm="Admin Panel"Note: HTTP Basic Auth transmits credentials in Base64, which is trivially reversible. Always use it behind TLS in production (
HTTPS).
Multiple credential pairs in credentials let you issue per-client credentials and revoke them individually without changing every consumer.
OAuth 2.0 Token Introspection
Validate opaque Bearer tokens by calling an RFC 7662 token introspection endpoint. The gateway posts the token to the configured introspectionUrl, authenticates itself with HTTP Basic auth using clientId/clientSecret, and allows the request through only when the introspection response returns active: true.
{
"baseURL": "/protected",
"proxy": { "target": "http://api-service:3010", "changeOrigin": true },
"auth": {
"enabled": true,
"strategy": "oauth2",
"introspectionUrl": "https://auth.example.com/oauth/introspect",
"clientId": "gateway-client",
"clientSecret": "s3cr3t"
}
}| Field | Type | Required | Description |
|---|---|---|---|
| enabled | boolean | ✅ | true to enforce, false to bypass. |
| strategy | "oauth2" | ✅ | — |
| introspectionUrl | string | ✅ | Full URL of the RFC 7662 introspection endpoint. |
| clientId | string | ✅ | Client ID used for HTTP Basic auth against the introspection endpoint. |
| clientSecret | string | ✅ | Client secret used for HTTP Basic auth against the introspection endpoint. |
| tokenTypeHint | string | — | token_type_hint parameter sent with the introspection request (default: "access_token"). |
| introspectionCacheTtlMs | number | — | Positive TTL in milliseconds for successful introspection results. Inactive results are not cached; a previously active token may remain accepted until its cached result expires, even after revocation or token expiry. Omit to introspect every request. |
How it works:
- The gateway extracts the
Bearer <token>from theAuthorizationheader. - It POSTs
token=<opaque_token>&token_type_hint=access_tokentointrospectionUrl. - The request to the introspection endpoint carries
Authorization: Basic base64(clientId:clientSecret). - If the endpoint responds with
{ "active": true }, the request is forwarded to the upstream. - Any other response —
active: false, a non-2xx HTTP status, or a network error — returns401 Unauthorizedto the client.
# 1. Get a token from your auth server (issuer-specific, not proxied through the gateway here)
TOKEN=$(curl -s -X POST https://auth.example.com/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"s3cr3t"}' | jq -r '.access_token')
# 2. Access the protected route
curl http://localhost:3000/protected \
-H "Authorization: Bearer $TOKEN"
# 3. Missing or forged token → 401
curl http://localhost:3000/protected \
-H "Authorization: Bearer fake-token"Tip: Use
introspectionCacheTtlMsto avoid hammering your auth server on high-traffic routes. Set it to a value shorter than your token expiry (e.g. 60 seconds) to keep revocation lag acceptable.
JWKS and Forwarded Claims
Use a JWKS endpoint instead of embedding a public key, and map verified claims into upstream headers:
{
"baseURL": "/identity",
"proxy": { "target": "http://identity-service:3001" },
"auth": {
"enabled": true,
"strategy": "jwt",
"jwksUri": "https://issuer.example.com/.well-known/jwks.json",
"algorithms": ["RS256"],
"forwardClaims": { "sub": "X-User-ID", "tenant": "X-Tenant-ID" }
}
}Tokens must carry a kid header matching a published key. Keys are cached in-process. An unknown kid triggers a refresh subject to a 60-second cooldown; cached keys have no fixed expiry. jwksUri takes precedence over publicKey and secret.
Claim forwarding runs after verification. Present, non-null claims overwrite the mapped request headers; missing claims leave existing headers unchanged. An upstream should not treat a mapped header as proof that a missing claim was verified. headers.request transforms run later and can override forwarded values.
Authentication Failure Limiting
Every auth strategy accepts authRateLimit:
{
"baseURL": "/admin",
"proxy": { "target": "http://admin-service:3001" },
"auth": {
"enabled": true,
"strategy": "basicAuth",
"credentials": [{ "username": "alice", "password": "example-password" }],
"authRateLimit": { "max": 5, "windowMs": 60000 }
}
}The route tracks JSON 401 responses by client IP. After five failures, subsequent requests from that IP return 429 until the window expires, including requests with valid credentials. This is separate from ordinary rateLimit, which counts requests. Counters live in-process per route and reset when the middleware is rebuilt. Try the Basic Auth example.
Request Validation
The optional validation block runs before webhook verification and authentication:
{
"baseURL": "/contacts",
"proxy": { "target": "http://localhost:4071" },
"validation": {
"allowedContentTypes": ["application/json"],
"requiredFields": ["name", "email"],
"maxBodyBytes": 1024
}
}| Field | Behavior | Rejection status |
| --- | --- | --- |
| allowedContentTypes | Case-insensitive substring match against the Content-Type header; an empty list disables this check | 415 |
| requiredFields | Requires an object body with non-null, defined top-level fields; does not validate field types or nested paths | 422 |
| maxBodyBytes | Positive integer compared against Content-Length when that header is present | 413 |
Checks run in the order above except that size is checked before required fields. They apply to every request on the route, including GET requests. The per-route size check does not measure chunked bodies; the Express parser's own limits still apply. See the validation example.
Webhook Verification
Set webhook to verify incoming signatures before forwarding. The gateway retains raw body bytes through its body parser and compares signatures with a timing-safe helper.
{
"baseURL": "/webhooks/github",
"proxy": { "target": "http://localhost:4072" },
"webhook": { "provider": "github", "secret": "example-webhook-secret" }
}| Provider | Signature header | Signed content and encoding |
| --- | --- | --- |
| github | x-hub-signature-256 | HMAC-SHA256 of raw body; header value sha256=<hex> |
| stripe | stripe-signature | HMAC-SHA256 of <timestamp>.<UTF-8 body>; header t=<timestamp>,v1=<hex> |
| custom | Required headerName, normalized to lowercase | Raw-body HMAC hex digest using hashAlgorithm (default sha256) |
All providers require a nonempty secret. GitHub and Stripe use fixed headers and algorithms; optional headerName and hashAlgorithm values are ignored for those providers. Custom configuration looks like:
{
"provider": "custom",
"secret": "example-webhook-secret",
"headerName": "X-Example-Signature",
"hashAlgorithm": "sha256"
}Missing or invalid signatures return 401. Unavailable raw body returns 400. Stripe currently checks the first t and v1 entries only and does not enforce timestamp freshness or replay prevention.
The library exports WebhookConfig as a discriminated union of GitHubWebhookConfig, StripeWebhookConfig, and CustomWebhookConfig, plus WebhookProvider. Provider verifier classes own header and signature rules; WebhookMiddlewareFactory delegates through a verifier resolver.
Run bash examples/run.sh webhook, then node examples/webhook/send.js github (or stripe / custom). The example includes its own upstream event receiver.
Upstream Request Signing
proxy.upstreamAuth signs the outgoing request body so the upstream can check that it was sent by a holder of the shared secret:
{
"baseURL": "/signed",
"proxy": {
"target": "http://localhost:4074",
"pathRewrite": { "^/signed": "" },
"upstreamAuth": {
"type": "hmac-sha256",
"secret": "example-upstream-secret",
"header": "x-gateway-signature"
}
},
"retry": { "attempts": 1, "delay": 0 }
}type and secret are required. The optional header defaults to x-gateway-signature and carries a lowercase hex HMAC-SHA256 digest with no prefix. The signature covers the bytes serialized for the upstream, which may differ from the original JSON formatting. It does not cover the URL, method, or timestamp.
Signing is currently implemented only by the retry backend: a retry block is required, and this backend does not proxy WebSocket upgrades. attempts: 1 allows one retry after the initial request; it does not disable retries. See the signing example for an upstream that checks signatures and rejects unsigned direct requests.
Traffic Mirroring
proxy.mirror copies traffic to another upstream for shadow testing:
{
"baseURL": "/mirrored",
"proxy": {
"target": "http://localhost:4075",
"pathRewrite": { "^/mirrored": "" },
"mirror": { "target": "http://localhost:4076", "percentage": 100 }
},
"retry": { "attempts": 1, "delay": 0 }
}target is required; percentage accepts 0–100 and defaults to 100. The retry backend dispatches the mirror after writing a returned primary response. Thrown primary errors do not trigger mirroring. The client does not wait for the shadow response, and mirror failures are logged at debug level.
Mirror requests use the route's path rewrite and serialized body. They do not apply proxy.upstreamAuth or headers.request transforms; incoming request headers are otherwise forwarded after transport header filtering. Choose a shadow target that is appropriate for that data. Like signing, mirroring requires a retry block and does not support WebSocket upgrades.
The mirroring example has separate primary and shadow JavaScript services. Send a request to /mirrored, then inspect http://localhost:4076/stats to see the shadow request count.
Request Timeout per Route
Set proxy.timeout on any route to limit how long the gateway waits for the upstream to respond. When the deadline is exceeded, the gateway immediately returns 504 Gateway Timeout to the client and cancels the upstream connection — preventing a slow or hung upstream from holding sockets open indefinitely.
Configuration
{
"baseURL": "/external-api",
"proxy": {
"target": "http://slow-third-party:8080",
"changeOrigin": true,
"pathRewrite": { "^/external-api": "" },
"timeout": 5000
}
}timeout is measured in milliseconds and applies to the total time waiting for the upstream to begin sending a response. Once the upstream starts streaming, the timer is cleared.
Response when timeout is exceeded
HTTP/1.1 504 Gateway Timeout
Content-Type: application/json
{
"error": "Gateway Timeout",
"message": "Upstream did not respond within 5000ms"
}The upstream connection is also aborted server-side, so resources are freed immediately.
Combining with the circuit breaker
Timeout and circuit breaking work independently and can be applied to the same route:
{
"baseURL": "/payments",
"proxy": {
"target": "http://payments-service:3004",
"changeOrigin": true,
"timeout": 3000
},
"circuitBreaker": {
"threshold": 5,
"timeout": 30000
}
}A request that times out counts as a circuit-breaker failure. After threshold timeouts the circuit opens and subsequent requests are rejected with 503 without even reaching the upstream.
Circuit Breaker
The circuit breaker protects your gateway from cascading failures. When an upstream service becomes unhealthy, the gateway detects the pattern, opens the circuit, and rejects subsequent requests immediately — without adding load to an already-struggling upstream.
State machine
threshold failures
CLOSED ────────────────────────► OPEN
▲ │
│ successThreshold successes │ timeout elapses
│ ▼
HALF-OPEN ◄─────────────────── (probe)
1 probe request let through| State | Behaviour |
|---|---|
| CLOSED | Normal operation. Failures are counted; successful responses reset the counter. |
| OPEN | All requests are rejected immediately with 503 Service Unavailable and a Retry-After header. The upstream is not contacted. |
| HALF-OPEN | After timeout ms the circuit allows one probe request through. A successful response closes the circuit; any failure re-opens it with a fresh timeout. |
Both 5xx HTTP responses and network-level errors (e.g. ECONNREFUSED, ETIMEDOUT) count as failures.
Configuration
{
"baseURL": "/payments",
"proxy": {
"target": "http://payments-service:3004",
"changeOrigin": true,
"pathRewrite": { "^/payments": "" }
},
"circuitBreaker": {
"threshold": 5,
"timeout": 30000,
"successThreshold": 1
}
}Responses
Circuit OPEN — 503 Service Unavailable:
HTTP/1.1 503 Service Unavailable
Retry-After: 28
Content-Type: application/json
{
"error": "Service Unavailable",
"message": "Circuit breaker open — upstream is not responding"
}The Retry-After header tells clients how many seconds remain before the circuit transitions to half-open.
Upstream network error — 502 Bad Gateway:
{
"error": "Bad Gateway",
"message": "Upstream service is unavailable"
}Gateway log output
State transitions are logged at the warn / info level so you can observe the circuit breaker lifecycle without instrumenting your upstreams:
WARN Circuit breaker opened, upstream failing { baseURL: "/payments", retryAfterSeconds: 30 }
WARN Circuit breaker half-open, probing upstream { baseURL: "/payments" }
INFO Circuit breaker closed, upstream recovered { baseURL: "/payments" }Combining with rate limiting
Circuit breaking and rate limiting are independent and can be applied to the same route. Rate limiting runs first:
{
"baseURL": "/payments",
"proxy": { "target": "http://payments-service:3004", "changeOrigin": true },
"rateLimit": { "max": 200, "windowMs": 60000 },
"circuitBreaker": { "threshold": 5, "timeout": 30000 }
}Load Balancing
Distribute traffic across multiple upstream instances by replacing the single proxy.target string with a proxy.targets array. Four strategies are supported.
Strategies
| Strategy | Behaviour |
|---|---|
| round-robin (default) | Cycles through targets in order: A → B → C → A → … |
| weighted | Each target carries traffic proportional to its weight. A target with weight: 2 receives twice as many requests as one with weight: 1. The cycle is deterministic (not random). |
| least-connections | Always forwards to the target with the fewest active connections. Best for routes where upstream processing time varies significantly. |
| sticky | Routes a given client to the same upstream on every request (session affinity). The client is identified by the stickyKey spec ("ip", "header:<name>", "jwt:<claim>", or "cookie:<name>"). On first contact the target is chosen by round-robin and stored in-process; subsequent requests from the same client always go to that target. Mappings are in-process only and reset on gateway restart. |
Configuration
Round-robin (3 equal instances):
{
"baseURL": "/catalog",
"proxy": {
"targets": [
{ "url": "http://catalog-a:3001" },
{ "url": "http://catalog-b:3002" },
{ "url": "http://catalog-c:3003" }
],
"changeOrigin": true,
"pathRewrite": { "^/catalog": "" }
}
}Weighted (B carries twice as much traffic as A or C):
{
"baseURL": "/catalog",
"proxy": {
"targets": [
{ "url": "http://catalog-a:3001", "weight": 1 },
{ "url": "http://catalog-b:3002", "weight": 2 },
{ "url": "http://catalog-c:3003", "weight": 1 }
],
"strategy": "weighted",
"changeOrigin": true
}
}Least-connections:
{
"baseURL": "/api",
"proxy": {
"targets": [
{ "url": "http://api-1:3001" },
{ "url": "http://api-2:3002" }
],
"strategy": "least-connections",
"changeOrigin": true
}
}Sticky sessions (session affinity):
{
"baseURL": "/checkout",
"proxy": {
"targets": [
{ "url": "http://checkout-a:3001" },
{ "url": "http://checkout-b:3002" }
],
"strategy": "sticky",
"stickyKey": "cookie:session_id",
"changeOrigin": true
}
}stickyKey is required for sticky routes and accepts five formats:
| Format | Example | Behaviour |
|---|---|---|
| "ip" | "ip" | Client IP address. |
| "header:<name>" | "header:X-Session-ID" | Value of the named request header. |
| "jwt:<claim>" | "jwt:sub" | Claim extracted from the decoded JWT Bearer token. |
| "cookie:<name>" | "cookie:session_id" | Value of the named cookie. |
| "query:<name>" | "query:session" | String query parameter; repeated or structured values are ignored. |
Cookie keys require middleware that populates req.cookies; the standalone gateway does not install cookie-parser. Sticky mappings reset on restart or route reload. JWT key extraction decodes claims; use JWT authentication on the route when the key must come from a verified token.
When the key source is absent (no cookie, no header, anonymous request) the gateway falls back to round-robin for that single request and pins the chosen target if a key becomes available on subsequent calls.
Composing with other features
Load balancing is fully composable with every other per-route feature:
{
"baseURL": "/api",
"proxy": {
"targets": [
{ "url": "http://api-1:3001", "weight": 2 },
{ "url": "http://api-2:3002", "weight": 1 }
],
"strategy": "weighted",
"changeOrigin": true
},
"rateLimit": { "max": 100, "windowMs": 60000 },
"auth": { "enabled": true, "strategy": "apiKey", "keys": ["svc-key-xyz"] },
"circuitBreaker": { "threshold": 5, "timeout": 30000 }
}The circuit breaker wraps the load-balanced pool as a whole. If the breaker opens, all targets are bypassed until recovery.
WebSocket Proxying
Enable WebSocket proxying by adding "ws": true to any proxy configuration. The gateway intercepts the HTTP Upgrade handshake on the raw TCP server and tunnels all subsequent WebSocket frames transparently to the upstream.
Configuration
{
"baseURL": "/chat",
"proxy": {
"target": "http://chat-service:4000",
"changeOrigin": true,
"pathRewrite": { "^/chat": "" },
"ws": true
}
}How it works
Standard HTTP requests to /chat are proxied normally. When a client sends an HTTP Upgrade request, the gateway attaches the proxy middleware's upgrade handler to the raw HTTP server's upgrade event, so WebSocket connections are forwarded at the transport level without involving Express middleware.
Usage
# Connect through the gateway (requires wscat: npm i -g wscat)
wscat -c ws://localhost:3000/chat
# Inspect the raw upgrade handshake
curl -si http://localhost:3000/chat \
-H "Upgrade: websocket" \
-H "Connection: Upgrade" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Version: 13" | head -6
# HTTP/1.1 101 Switching Protocols
# Upgrade: websocket
# Connection: Upgrade
# Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=Notes
ws: truecan be combined withpathRewrite,headers,changeOrigin, andtimeout.- WebSocket and load balancing can be combined. Note that WebSocket connections are long-lived;
least-connectionsis the most effective strategy in that case since it naturally directs new connections to the least-loaded instance. - Auth, rate limiting, and IP filtering apply only to the initial HTTP Upgrade request. Once the connection is established, frames flow directly through the proxy.
Request ID Propagation
Every request that passes through the gateway is assigned a unique X-Request-ID header. This ID ties together the gateway log line, the request forwarded to the upstream, and the response returned to the caller — letting you trace any individual request across your entire stack with a single value.
Behaviour
| Scenario | Result |
|---|---|
| Client sends no X-Request-ID header | Gateway generates a UUID v4 and injects it |
| Client sends X-Request-ID: <value> | Gateway forwards the existing value unchanged |
In both cases the final ID is:
- set on
req.headersso it is forwarded to the upstream in the proxy request - echoed in the
X-Request-IDresponse header so callers can log it - used as
req.idin everypino-httplog line for that request
No route configuration is required — propagation is automatic for every route.
Gateway log correlation
INFO incoming request { "req": { "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "method": "GET", "url": "/orders" } }
INFO request completed { "req": { "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479" }, "res": { "statusCode": 200 } }Usage
# Gateway generates a UUID — echoed in the response header
curl -si http://localhost:3000/inventory | grep -i x-request-id
# X-Request-ID: f47ac10b-58cc-4372-a567-0e02b2c3d479
# Supply your own ID — forwarded unchanged
curl -si http://localhost:3000/inventory \
-H "X-Request-ID: my-trace-abc-123" | grep -i x-request-id
# X-Request-ID: my-trace-abc-123IP Allowlist / Blocklist
Restrict access to any route by the client's IP address. Exact IPv4 and IPv6 addresses and CIDR ranges are supported. deny and allow can be combined on the same route.
Evaluation order
1. deny — if the client IP matches any deny entry → 403 Forbidden (stop)
2. allow — if set and the client IP does not match any allow entry → 403 Forbidden (stop)
3. — request is forwarded to the upstreamWhen only deny is configured every IP passes except those explicitly blocked.
When only allow is configured only listed IPs pass.
When both are present deny takes precedence.
Configuration
{
"baseURL": "/internal/metrics",
"proxy": { "target": "http://metrics-service:3005", "changeOrigin": true },
"ipFilter": {
"allow": ["10.0.0.0/8", "172.16.0.0/12"]
}
}{
"baseURL": "/public-api",
"proxy": { "target": "http://api-service:3006", "changeOrigin": true },
"ipFilter": {
"deny": ["203.0.113.0/24"]
}
}Response when blocked
HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"error": "Forbidden",
"message": "Your IP address is not permitted to access this resource"
}The upstream never receives the request — filtering happens in the gateway middleware before the proxy is invoked.
IPv6 normalisation
IPv4-mapped IPv6 addresses (::ffff:192.168.1.1) are silently normalised to their IPv4 form before matching. You only need to list the IPv4 address in the config — both forms are covered automatically.
Combining with other features
IP filtering runs after per-route CORS and before validation, authentication, and rate limiting. A blocked request never reaches the auth check and does not count against rate limit counters.
{
"baseURL": "/admin",
"proxy": { "target": "http://admin-service:3007", "changeOrigin": true },
"ipFilter": { "allow": ["10.0.0.0/8"] },
"auth": { "enabled": true, "strategy": "apiKey", "keys": ["admin-key-xyz"] },
"rateLimit": { "max": 50, "windowMs": 60000 }
}Retry with Backoff
Automatically retry failed upstream requests before returning a failure to the client. The gateway buffers the full upstream response on each attempt — it never streams a 5xx to the client — so retries are completely transparent. Both HTTP 5xx responses and network-level errors (e.g. ECONNREFUSED) trigger a retry.
Configuration
{
"baseURL": "/inventory",
"proxy": {
"target": "http://inventory-service:3001",
"changeOrigin": true,
"pathRewrite": { "^/inventory": "" }
},
"retry": {
"attempts": 3,
"delay": 200,
"backoff": "exponential"
}
}With attempts: 3, the gateway calls the upstream up to 4 times total (1 initial + 3 retries), subject to HTTP method eligibility. See the backend limitation below for delay behavior.
Backoff strategies
| Strategy | Delay formula | Example (delay: 200) |
|---|---|---|
| "fixed" | delay | 200 ms, 200 ms, 200 ms |
| "exponential" | delay × 2^n | 200 ms, 400 ms, 800 ms |
| "exponential-jitter" | Uniform random value below delay × 2^n | Below 200 ms, 400 ms, 800 ms |
The exponential strategies use RETRY_BACKOFF_MULTIPLIER (default 2). These strategy classes exist, but the current route backend constructs RetryExecutor with its default FixedBackoff; selecting backoff in route JSON does not yet change the actual delays. A custom executor can receive an explicit strategy.
Retry methods, fallback, and request collapsing
By default, only GET, HEAD, and OPTIONS responses are eligible for HTTP-status retries. Set retryMethods to opt other methods in. Network errors currently retry regardless of retryMethods, so this option is not a guarantee against repeating a write after a transport failure.
{
"baseURL": "/public-catalog",
"proxy": { "target": "http://catalog-service:3001" },
"retry": {
"attempts": 2,
"delay": 100,
"retryOn": [502, 503, 504],
"retryMethods": ["GET", "HEAD"],
"collapseRequests": true,
"fallback": {
"status": 503,
"body": { "message": "Catalog temporarily unavailable" }
}
}
}fallback handles thrown proxy/retry errors, such as exhausted network failures. A final HTTP response from the upstream is forwarded, even when its status is retryable; it does not activate this fallback. Circuit-breaker fallback is separate and applies when its guard rejects a request.
collapseRequests shares one in-flight execution among concurrent GET, HEAD, or OPTIONS requests with the same method and URL. It is not response caching. The key excludes authorization, cookies, headers, and body, so enable it only where those differences cannot change the response, such as a public catalog. Shared requests also use the initiating execution's abort signal.
Response when all attempts fail
HTTP/1.1 500 Internal Server Error
Content-Type: application/json
{
"error": "Bad Gateway",
"message": "Upstream returned 503"
}The status code mirrors the last upstream failure. The client receives no indication of how many retries occurred.
Composing with load balancing and circuit breaker
{
"baseURL": "/api",
"proxy": {
"targets": [
{ "url": "http://api-1:3001" },
{ "url": "http://api-2:3002" }
],
"strategy": "round-robin"
},
"retry": { "attempts": 2, "delay": 100, "backoff": "exponential" },
"circuitBreaker": { "threshold": 5, "timeout": 30000 }
}Each retry attempt selects the next target from the load balancer independently. Circuit-breaker failures are recorded on every failing attempt.
Note: WebSocket routes (
ws: true) do not support retry — the connection upgrade is a one-shot handshake.
Response Caching
Cache successful upstream responses in memory per route. Once cached, subsequent requests matching the same method and URL are served from the cache without hitting the upstream at all — reducing latency and upstream load for read-heavy endpoints.
Configuration
{
"baseURL": "/catalog",
"proxy": {
"target": "http://catalog-service:3002",
"changeOrigin": true,
"pathRewrite": { "^/catalog": "" }
},
"cache": {
"ttl": 30000,
"methods": ["GET", "HEAD"],
"statusCodes": [200]
}
}Options
| Field | Default | Description |
|---|---|---|
| ttl | — (required) | Time-to-live in milliseconds. The cached entry is evicted on the next access after TTL expires. |
| methods | ["GET", "HEAD"] | HTTP methods to cache. |
| statusCodes | [200, 203, 204] | Upstream status codes to cache. Non-matching responses are always forwarded without caching. |
Cache key
The cache key is METHOD:originalURL. Each route maintains its own independent cache store, so /catalog and /catalog/1 have separate entries even when they share the same route.
Response headers
| Header | Value | Description |
|---|---|---|
| X-Cache | MISS | First request — served from upstream and stored. |
| X-Cache | HIT | Subsequent requests — served from cache; upstream not contacted. |
# First request — cache MISS (~200 ms upstream latency)
curl -si http://localhost:3000/catalog | grep X-Cache
# X-Cache: MISS
# Subsequent requests — cache HIT (< 5 ms)
curl -si http://localhost:3000/catalog | grep X-Cache
# X-Cache: HIT
# POST bypasses the cache (not in `methods`)
curl -s -X POST http://localhost:3000/catalog \
-H "Content-Type: application/json" \
-d '{"name":"Widget"}' | head -1Prometheus Metrics
The gateway exposes a GET /metrics endpoint in Prometheus text format. Scrape it with any Prometheus-compatible monitoring stack (Prometheus, Grafana, Datadog Agent, etc.).
GET /metricsMetrics
| Metric | Type | Labels | Description |
|---|---|---|---|
| gateway_requests_total | Counter | route, method, status_code | Total number of requests processed per route. |
| gateway_request_duration_seconds | Histogram | route, method | End-to-end request latency in seconds (client → upstream → client). |
| gateway_upstream_errors_total | Counter | route, error_type | Upstream errors (5xx responses or network errors) per route. |
| gateway_cache_hits_total | Counter | route | Number of responses served from the in-memory cache per route. |
Example
# View all gateway metrics
curl -s http://localhost:3000/metrics | grep '^gateway_'
# Filter to a specific metric
curl -s http://localhost:3000/metrics | grep gateway_requests_total
# gateway_requests_total{route="/orders",method="GET",status_code="200"} 42
# gateway_requests_total{route="/orders",method="POST",status_code="201"} 7Prometheus scrape config
scrape_configs:
- job_name: api-gateway
static_configs:
- targets: ['localhost:3000']
metrics_path: /metricsIf GATEWAY_PREFIX is set, the endpoint is available at <GATEWAY_PREFIX>/metrics.
Header Transformation
Add, override, or remove individual headers on the outgoing upstream request and/or the response returned to the client — without touching any upstream code.
Configuration
{
"baseURL": "/api",
"proxy": {
"target": "http://api-service:3001",
"changeOrigin": true
},
"headers": {
"request": {
"set": {
"X-Forwarded-By": "api-gateway",
"X-Api-Version": "2",
"X-Internal-Token": "secret-only-upstreams-see"
},
"remove": ["User-Agent", "X-Powered-By"]
},
"response": {
"set": {
"X-Frame-Options": "DENY",
"Cache-Control": "no-store"
},
"remove": ["Server", "X-Powered-By"]
}
}
}headers.request
Applied to every request before it is forwarded to the upstream.
| Sub-field | Type | Description |
|---|---|---|
| set | Record<string, string> | Headers to add or override. Applied after all client headers are copied, so these always win. |
| remove | string[] | Headers to strip before forwarding. Header names are case-insensitive. |
headers.response
Applied to every response from the upstream before it is returned to the client.
| Sub-field | Type | Description |
|---|---|---|
| set | Record<string, string> | Headers to add or override on the client-facing response. |
| remove | string[] | Headers to strip from the upstream response before returning to the client. |
Common use cases
- Add an internal secret header the upstream trusts but the client never sends.
- Strip
Server/X-Powered-Byto avoid leaking implementation details. - Inject
X-Frame-Optionsor other security headers on responses from upstreams that don't set them. - Normalise versioning with
X-Api-Versionacross a mixed fleet of upstreams.
Header transforms are fully composable with all other per-route features (auth, rate limiting, caching, retry, etc.).
Route-Level CORS Override
The global CORS policy (configured via CORS_ORIGINS, CORS_METHODS, `CORS_HEADE
