Web APIs
Mini Project — API Gateway
Build a working API Gateway from scratch that handles routing, authentication, rate limiting, and request logging across multiple backend services.
Netflix does not expose 700 microservices directly to the internet. Neither does Uber, Stripe, or GitHub. Every one of those companies has a single entry point that all traffic passes through before it ever reaches a backend service. That entry point is an API Gateway — and building one yourself is the fastest way to understand why every serious production system has one.
An API Gateway is a server that sits in front of your backend services. Every client request hits the gateway first. The gateway decides whether the request is authenticated, whether the client has exceeded their rate limit, which backend service should handle it, and what gets logged. The individual services behind it never deal with any of that — they just handle business logic.
The APIForge DevOps team has three separate microservices: a Projects Service on port 3001, a Users Service on port 3002, and a Analytics Service on port 3003. Right now, clients talk to each service directly. That means each service has its own auth logic, its own rate limiting, and its own logging — duplicated three times. This project fixes that by putting a gateway in front of all three.
/api/projects/*, /api/users/*, and /api/analytics/* to the right service, with auth, rate limiting, and logging applied to everything.Build Step 1 — Project Setup and Stub Services
Before building the gateway, you need the three backend services running so there is something to proxy to. Each stub service is a minimal Express app that returns a hardcoded JSON response — enough to prove the gateway is routing correctly without building full business logic.
Create a single Node.js project that will contain the gateway and all three stub services. Each service runs as a separate process on its own port. Install everything in one go so all packages are available across the project.
# WHAT: APIForge gateway project — initialise and install all packages
# Run these commands in your terminal from an empty project folder
mkdir apiforge-gateway && cd apiforge-gateway
npm init -y
# Gateway dependencies
npm install express http-proxy-middleware jsonwebtoken ioredis morgan dotenv
# Dev tooling
npm install --save-dev nodemon
# Create folder structure
mkdir services
touch gateway.js services/projects.js services/users.js services/analytics.js .env
# Project structure when done:
# apiforge-gateway/
# ├── gateway.js ← the API gateway (port 3000)
# ├── .env ← JWT_SECRET and REDIS_URL
# ├── services/
# │ ├── projects.js ← stub Projects Service (port 3001)
# │ ├── users.js ← stub Users Service (port 3002)
# │ └── analytics.js ← stub Analytics Service (port 3003)
# └── package.json
http-proxy-middleware is what does the actual request forwarding — it receives a request at the gateway and replays it to the target service, then passes the response back. ioredis is a Redis client for Node.js that will store rate limit counters. jsonwebtoken verifies JWT tokens without calling an auth service on every request — the gateway checks the token's signature locally using the shared secret.
The folder structure keeps the gateway logic separate from the individual services. In a real production system these would be separate repositories and separate deployments — but for this project, running them as separate Node processes on different ports achieves the same result.
Try this: Open four terminal tabs. You will run one process per tab — one gateway and one per service. Keeping them separate makes the logs easy to read.Build Step 2 — Stub Backend Services
Each stub service is a minimal Express server. They do not connect to a database — they return realistic JSON responses so you can verify the gateway is routing traffic to the right place. Once the gateway is working, you replace these stubs with real services without changing a single line of gateway code.
Each service listens on its own port, returns a JSON response identifying itself, and echoes back the incoming request headers so you can confirm what the gateway is forwarding.
// WHAT: APIForge stub services — all three files share the same pattern
// File: services/projects.js (repeat for users.js :3002, analytics.js :3003)
import express from 'express';
const app = express();
const PORT = 3001; // 3002 for users, 3003 for analytics
app.use(express.json());
// Health check
app.get('/health', (req, res) => {
res.json({ service: 'projects', status: 'ok', port: PORT });
});
// Main resource endpoint — echoes back the forwarded user info
app.get('/api/projects', (req, res) => {
res.json({
service: 'Projects Service',
port: PORT,
user: req.headers['x-user-id'] || 'unknown',
email: req.headers['x-user-email'] || 'unknown',
data: [
{ id: 'proj_001', name: 'API Gateway Redesign', status: 'active' },
{ id: 'proj_002', name: 'Auth Service Upgrade', status: 'active' },
{ id: 'proj_003', name: 'Rate Limiter v2', status: 'planning' },
],
});
});
app.listen(PORT, () => {
console.log(`Projects Service running on port ${PORT}`);
});
// ── services/users.js (port 3002) ───────────────────────────────────────────
// Same pattern, different port and data:
// app.get('/api/users', ...) → returns { service: 'Users Service', port: 3002, ... }
// ── services/analytics.js (port 3003) ──────────────────────────────────────
// app.get('/api/analytics', ...) → returns { service: 'Analytics Service', port: 3003, ... }
Each service echoes back x-user-id and x-user-email from the request headers. These headers do not exist yet — they show as "unknown" when you hit the service directly. Once the gateway is running, it will extract those values from the JWT and inject them as headers before forwarding. That is the cleanest way to pass identity to downstream services without making each service verify the token itself.
Notice the services have no auth logic, no rate limiting, no logging. All of that lives in one place — the gateway. If you need to change how auth works, you change it once.
Try this: Hit all three services directly with curl or Postman now. Confirm you get 200 responses. This is your baseline — everything should still return 200 after the gateway is in front.Build Step 3 — JWT Authentication Middleware
The gateway's first middleware job is authentication. Every request that arrives must carry a valid JWT in the Authorization: Bearer header. The gateway verifies the token's signature locally — no round trip to an auth service — extracts the user's ID and email, then injects those values as request headers so the downstream services know who is calling them.
This middleware runs on every request. If the token is missing or invalid, the request is rejected with a 401 before it ever reaches a backend service. If the token is valid, the decoded user data is attached to the request object and injected as headers for the downstream service to read.
// WHAT: APIForge gateway.js — JWT authentication middleware
// File: gateway.js (partial — this is the auth middleware section)
// JWT_SECRET must match the secret used when tokens were originally issued
import express from 'express';
import jwt from 'jsonwebtoken';
import 'dotenv/config';
const app = express();
// ── JWT Authentication Middleware ───────────────────────────────────────────
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Extract from "Bearer "
if (!token) {
return res.status(401).json({
error: 'Unauthorized',
message: 'No token provided. Include Authorization: Bearer in your request.',
});
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Attach decoded user to the request for downstream middleware
req.user = decoded;
// Inject user identity as headers so backend services can read them
// without needing to verify the JWT themselves
req.headers['x-user-id'] = decoded.userId;
req.headers['x-user-email'] = decoded.email;
req.headers['x-user-role'] = decoded.role || 'user';
// Remove the raw JWT — downstream services do not need it
delete req.headers['authorization'];
next();
} catch (err) {
const message = err.name === 'TokenExpiredError'
? 'Token has expired. Please re-authenticate.'
: 'Invalid token. Authentication failed.';
return res.status(401).json({ error: 'Unauthorized', message });
}
}
// Apply auth to all routes
app.use(authenticateToken);
jwt.verify() does two things at once: it checks the token's cryptographic signature against your secret key, and it checks the token's expiry timestamp. If either fails, it throws an error that the catch block turns into a clean 401 response. No token ever reaches a backend service without passing this check.
Deleting the raw Authorization header before forwarding is a deliberate security decision. Backend services should trust the x-user-id header injected by the gateway — not re-verify the JWT themselves. That trust only works if the services are not exposed to the internet directly, which is exactly the point of a gateway.
jwt.sign({ userId: 'usr_test', email: 'test@apiforge.dev' }, process.env.JWT_SECRET, { expiresIn: '1h' }) in a small script and use that token for all remaining tests.
Build Step 4 — Redis Rate Limiting Middleware
After auth passes, the next check is rate limiting. The gateway allows each authenticated user a maximum of 100 requests per minute. The counter lives in Redis so it works correctly even if the gateway is running as multiple instances behind a load balancer — a single in-memory counter would not survive that setup.
The implementation uses a sliding window approach: Redis stores a counter per user keyed to the current minute. Each request increments the counter. When the counter hits 100, the request is rejected with a 429. The counter expires automatically after 60 seconds — Redis TTL handles the reset without any cron job or cleanup code.
Rate limit counters are keyed by user ID and the current minute timestamp — so each user gets a fresh window every 60 seconds. The response always includes headers telling the client how many requests they have left and when the window resets.
// WHAT: APIForge gateway.js — Redis rate limiting middleware
// Limit: 100 requests per user per minute
// Redis key format: ratelimit:{userId}:{currentMinute}
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
const LIMIT = 100; // max requests per window
const WINDOW_S = 60; // window size in seconds
async function rateLimiter(req, res, next) {
const userId = req.user.userId;
const minute = Math.floor(Date.now() / 1000 / WINDOW_S); // changes every 60s
const key = `ratelimit:${userId}:${minute}`;
// Increment counter — if key does not exist, Redis creates it starting at 1
const current = await redis.incr(key);
// Set TTL only on the first request of the window (when counter was just created)
if (current === 1) {
await redis.expire(key, WINDOW_S);
}
const remaining = Math.max(0, LIMIT - current);
const resetAt = (minute + 1) * WINDOW_S; // unix timestamp of next window
// Always send rate limit headers so clients can track their own usage
res.setHeader('X-RateLimit-Limit', LIMIT);
res.setHeader('X-RateLimit-Remaining', remaining);
res.setHeader('X-RateLimit-Reset', resetAt);
if (current > LIMIT) {
return res.status(429).json({
error: 'Too Many Requests',
message: `Rate limit exceeded. You are allowed ${LIMIT} requests per minute.`,
resetAt: new Date(resetAt * 1000).toISOString(),
});
}
next();
}
// Apply after auth middleware
app.use(rateLimiter);
redis.incr(key) is an atomic operation — even with thousands of concurrent requests hitting the gateway simultaneously, Redis processes each increment one at a time. There is no race condition where two requests both read "99" and both proceed, pushing the total to 101 before the block fires. That atomicity is the reason Redis is the right tool here instead of an in-memory variable.
The X-RateLimit-* headers follow the conventions used by Stripe, GitHub, and Twilio. Any client that respects these headers can back off automatically before hitting the 429 — reducing error noise on both sides.
KEYS ratelimit:* while sending requests to watch the counters appear and expire in real time.
Build Step 5 — Request Logging and Proxy Routing
With auth and rate limiting in place, the final two pieces are logging and routing. Logging captures every request — method, path, status code, response time, and user ID — to a structured format that can be shipped to any log aggregator. Routing uses http-proxy-middleware to forward requests to the right backend service based on the URL prefix.
This is the final piece. The complete gateway.js file brings everything together: morgan for structured logging, three proxy routes, and the server startup. After this step the gateway is fully operational.
// WHAT: Complete gateway.js — full APIForge API Gateway
// Combines: logging → auth → rate limiting → proxy routing
// File: gateway.js
import express from 'express';
import jwt from 'jsonwebtoken';
import Redis from 'ioredis';
import morgan from 'morgan';
import { createProxyMiddleware } from 'http-proxy-middleware';
import 'dotenv/config';
const app = express();
const redis = new Redis(process.env.REDIS_URL);
// ── 1. Structured request logging ──────────────────────────────────────────
morgan.token('user-id', (req) => req.user?.userId || 'unauthenticated');
app.use(morgan(
':method :url :status :res[content-length]B :response-time ms user=:user-id',
{ stream: { write: (msg) => console.log('[GATEWAY]', msg.trim()) } }
));
// ── 2. JWT Auth middleware (from Step 3) ────────────────────────────────────
app.use(authenticateToken); // defined in Step 3
// ── 3. Rate limiter middleware (from Step 4) ────────────────────────────────
app.use(rateLimiter); // defined in Step 4
// ── 4. Proxy routes ─────────────────────────────────────────────────────────
const proxyOptions = (target) => ({
target,
changeOrigin: true,
on: {
error: (err, req, res) => {
console.error(`[GATEWAY] Proxy error → ${target}:`, err.message);
res.status(502).json({
error: 'Bad Gateway',
message: `Upstream service at ${target} is unavailable.`,
});
},
proxyReq: (proxyReq, req) => {
// Log which service is receiving the request
console.log(`[GATEWAY] → Proxying to ${target}${req.path}`);
},
},
});
// Route /api/projects/* → Projects Service on port 3001
app.use('/api/projects', createProxyMiddleware(proxyOptions('http://localhost:3001')));
// Route /api/users/* → Users Service on port 3002
app.use('/api/users', createProxyMiddleware(proxyOptions('http://localhost:3002')));
// Route /api/analytics/* → Analytics Service on port 3003
app.use('/api/analytics', createProxyMiddleware(proxyOptions('http://localhost:3003')));
// Gateway health check — does not require auth
app.get('/health', (req, res) => {
res.json({ gateway: 'ok', timestamp: new Date().toISOString() });
});
// Catch-all for unknown routes
app.use((req, res) => {
res.status(404).json({
error: 'Not Found',
message: `No route configured for ${req.method} ${req.path}`,
});
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`[GATEWAY] APIForge API Gateway running on port ${PORT}`);
console.log(`[GATEWAY] Routes: /api/projects → :3001 | /api/users → :3002 | /api/analytics → :3003`);
});
createProxyMiddleware forwards the entire incoming request — method, headers, body, query params — to the target service and streams the response back. The changeOrigin: true option rewrites the Host header to match the target, which most services require. The error handler catches connection failures and returns a clean 502 instead of an unhandled crash.
Look at the Projects Service response — "user": "usr_9f3k2m" and "email": "priya@apiforge.dev" are now populated. The service received those values from the gateway's injected headers — it never saw the JWT at all. That is the gateway pattern working exactly as intended.
Before and After
The difference between directly-exposed microservices and a gateway-fronted architecture is not subtle. Every problem that existed before the gateway is now solved in one place — and adding a fourth or fifth service costs almost nothing.
app.use() lineFinal Result
The APIForge API Gateway is a fully working reverse proxy with production-grade middleware. Here is the complete picture of what was built across five steps:
| Component | What It Does | Result |
|---|---|---|
| Morgan Logger | Logs every request with method, path, status, size, response time, and user ID | Full audit trail of all gateway traffic |
| JWT Auth Middleware | Verifies token, injects user headers, rejects unauthenticated requests with 401 | No unauthenticated request reaches any service |
| Redis Rate Limiter | 100 req/min per user, atomic Redis counter, auto-expiring window, 429 on breach | Abuse protection that scales horizontally |
| Proxy Router | Routes /api/projects, /api/users, /api/analytics to the correct upstream service | Single client URL, three independent services |
| Error Boundaries | 502 for upstream failures, 404 for unknown routes, structured JSON for all errors | Clients always get a clean, parseable error |
Quiz
1. The APIForge gateway removes the Authorization header before forwarding a request and replaces it with x-user-id and x-user-email headers. Why is this the correct design?
2. The APIForge rate limiter uses redis.incr() rather than reading the counter, adding 1, and writing it back. Why does this matter under high traffic?
3. A developer stops the Analytics Service while the APIForge gateway is running. What happens when a client sends GET /api/analytics to the gateway?