@derian-cordoba/api-gateway
v1.1.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 rate limiting, request validation, structured logging, and full security headers out of the box.
Table of Contents
- Features
- Requirements
- Getting Started
- Configuration
- Authentication
- Running the Gateway
- Health Check
- Project Structure
- Example Project
- Architecture
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) or an API key; set
enabled: falseto bypass with zero overhead - Per-route rate limiting — each route can declare its own
maxrequests /windowMswindow, enforced byexpress-rate-limit - 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) - Graceful shutdown —
SIGINTanduncaughtExceptionhandlers stop the server cleanly before exiting
Requirements
- Node.js 18+
- 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 devConfiguration
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. |
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
rateLimit?: RateLimit // optional — per-route rate limiting
}Proxy
| Field | Type | Required | Description |
|---|---|---|---|
| target | string | ✅ | Target upstream URL (must be a valid URL). |
| 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 | — | Proxy request timeout in milliseconds. |
RateLimit
| Field | Type | Required | Description |
|---|---|---|---|
| max | number | ✅ | Maximum number of requests allowed per window. |
| windowMs | number | ✅ | Time window in milliseconds. |
| statusCode | number | — | HTTP status returned when the limit is exceeded (default: 429). |
| message | string | — | Response message when the limit is exceeded (default: "Too many requests"). |
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.
Two strategies are supported, selected with the strategy field.
"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. |
The gateway expects the token in the Authorization: Bearer <token> header and returns 401 on a missing, malformed, or invalid token.
"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). |
Returns 401 when the header is absent or the value is not in keys.
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"
}
},
{
"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"]
}
}
]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 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"
}
}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 always takes precedence over secret. If neither is present the gateway returns 401.
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.
Running the Gateway
Development (hot-reload)
pnpm devUses ts-node-dev to transpile on the fly and restart on file changes. Logs are pretty-printed via pino-pretty.
Production
# Compile TypeScript
pnpm build
# Run the compiled output
pnpm startIn production (NODE_ENV=production) logs are emitted as newline-delimited JSON suitable for log aggregators (Datadog, Loki, CloudWatch, etc.).
Health Check
The gateway exposes a built-in health check endpoint that is always available, independent of the configured proxy routes.
GET /healthIf GATEWAY_PREFIX is set, the endpoint is available at <GATEWAY_PREFIX>/health.
Response 200 OK:
{
"status": "ok",
"uptime": 42,
"version": "1.0.0",
"timestamp": "2026-06-18T00:00:00.000Z"
}| Field | Description |
|---|---|
| status | Always "ok" when the process is alive. |
| uptime | Seconds since the gateway process started. |
| version | Value of npm_package_version (set automatically by npm/pnpm). |
| timestamp | ISO 8601 timestamp of the response. |
Project Structure
src/apps/api-gateway/
├── index.ts # Entry point — bootstraps the app, registers process signals
├── App.ts # Thin lifecycle wrapper (start / stop)
├── Server.ts # HTTP server creation and prefix mounting
├── logger.ts # Pino logger singleton
│
├── config/
│ ├── app-env.ts # Aggregates all config modules into a single AppEnv object
│ ├── env/config.ts # NODE_ENV → isDev flag
│ ├── gateway/config.ts # GATEWAY_PORT, GATEWAY_PREFIX
│ ├── cors/config.ts # CORS_ORIGINS, CORS_METHODS, CORS_HEADERS
│ ├── routes/config.ts # ROUTES_FILE_PATH
│ └── auth/config.ts # JWT_SECRET, JWT_PUBLIC_KEY
│
├── middleware/
│ ├── authMiddleware.ts # Factory — returns the right strategy or a no-op
│ └── auth/
│ ├── AuthStrategy.ts # Interface (Strategy pattern)
│ ├── JwtAuthStrategy.ts # JWT Bearer token validation (HMAC + RSA/EC)
│ └── ApiKeyAuthStrategy.ts # Header-based API key validation
│
├── routes/
│ ├── Router.ts # Middleware pipeline (logging → security → CORS → body → proxy → errors)
│ ├── ProxyManager.ts # Reads, validates, and registers proxy routes
│ ├── RouteValidator.ts # Zod schemas for Gateway / Proxy / RateLimit / Auth types
│ └── HealthRouter.ts # GET /health handler
│
└── types/
├── gateway.d.ts # Gateway type
├── proxy.d.ts # Proxy type
├── rate-limit.d.ts # RateLimit type
└── auth.d.ts # Auth type (JwtAuth | ApiKeyAuth)Example Project
examples/ contains a self-contained demo that starts five upstream services and one gateway covering all features: rate limiting, JWT auth, and API key auth.
pnpm exampleThis copies examples/.env to the project root and starts everything. Once running:
| Endpoint | Auth | Description |
|---|---|---|
| http://localhost:3000/health | — | Gateway health check |
| http://localhost:3000/users | — | Users service (rate limited) |
| http://localhost:3000/products | — | Products service (rate limited) |
| http://localhost:3000/auth/login | — | Issues JWT tokens |
| http://localhost:3000/orders | JWT Bearer | Orders service |
| http://localhost:3000/reports | API key | Reports service |
Public routes
curl http://localhost:3000/users
curl http://localhost:3000/products/1JWT-protected route (/orders)
# 1. Log in to get a token (users: alice/password123, bob/password456)
TOKEN=$(curl -s -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"password123"}' | jq -r '.token')
# 2. Access the protected route
curl http://localhost:3000/orders \
-H "Authorization: Bearer $TOKEN"
# 3. Create an order
curl -X POST http://localhost:3000/orders \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"userId":1,"items":[{"productId":2,"qty":1}],"total":24.99}'API-key-protected route (/reports)
# Valid keys: key-service-alpha-123 · key-service-beta-456
curl http://localhost:3000/reports \
-H "x-api-key: key-service-alpha-123"
curl http://localhost:3000/reports/sales \
-H "x-api-key: key-service-beta-456"Individual example directories
Each sub-directory is also usable as a standalone reference:
| Directory | Description |
|---|---|
| examples/basic/ | Rate limiting only — Users + Products services |
| examples/jwt-auth/ | JWT auth — Auth service + Orders service |
| examples/api-key-auth/ | API key auth — Reports service |
Architecture
Request lifecycle
Client
│
▼
Express app
│
├─ pino-http structured request/response logging
├─ helmet security headers (CSP, HSTS, X-Frame-Options, …)
├─ cors configurable origin / method / header policy
├─ express.json body parsing
├─ compression gzip response compression
│
├─ GET /health health check — short-circuits here
│
├─ authMiddleware per-route — JWT or API key check → 401 on failure
├─ express-rate-limit per-route request throttling → 429 on exceeded
├─ http-proxy-middleware proxies request to upstream, forwards body
│
└─ error handler catches unhandled errors → 500 JSON responseStartup sequence
index.ts
│
├─ dotenv.config() load .env before any module reads process.env
├─ new App()
│ └─ new Server()
│ ├─ appEnv resolved config modules read from process.env
│ └─ new Router()
│
└─ app.start()
├─ router.init() async: reads routes file, validates, registers middleware
└─ httpServer.listen() starts accepting connections only after routes are readyRoute loading
Routes are loaded from two sources at startup and merged into a single array before validation:
ROUTES_FILE_PATH (JSON file) ──┐
├──► merge ──► Zod validation ──► register proxy routes
ROUTES (env var JSON array) ──┘If either source is missing or contains invalid JSON it is skipped with a warning. If the merged result fails Zod validation, the process exits with a descriptive error.
