WEB API's Lesson 33 – Mini Project - API Gateway | Dataplexa
Web APIs · Lesson 33

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.

Project Brief
WHAT YOU ARE BUILDING
A Node.js API Gateway that proxies requests to three backend services, enforces JWT authentication, applies per-client rate limiting with Redis, and logs every request with response time.
WHAT YOU WILL LEARN
Reverse proxying, middleware chaining, JWT verification at the gateway layer, sliding-window rate limiting, and structured request logging — all in one project.
TECH STACK
Node.js · Express · http-proxy-middleware · jsonwebtoken · Redis (ioredis) · morgan
END RESULT
A single gateway on port 3000 that routes /api/projects/*, /api/users/*, and /api/analytics/* to the right service, with auth, rate limiting, and logging applied to everything.
Gateway Architecture — Request Flow
CLIENT
Any HTTP Client
Postman, browser, mobile app
GATEWAY :3000
1. Log request
2. Verify JWT
3. Check rate limit
4. Route + proxy
Projects Service :3001
Users Service :3002
Analytics Service :3003

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.

1
Initialise the project and install dependencies

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
added 97 packages in 4.3s Directory created: apiforge-gateway/ Files created: gateway.js .env services/projects.js services/users.js services/analytics.js package.json scripts (add these manually): { "scripts": { "gateway": "nodemon gateway.js", "projects": "node services/projects.js", "users": "node services/users.js", "analytics": "node services/analytics.js" } } .env contents to add: JWT_SECRET=apiforge_super_secret_key_2024 REDIS_URL=redis://localhost:6379 PORT=3000 Ready. Build the three stub services next.
What just happened?

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.

2
Write the three stub services

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, ... }
Terminal 1 — Projects Service: Projects Service running on port 3001 Terminal 2 — Users Service: Users Service running on port 3002 Terminal 3 — Analytics Service: Analytics Service running on port 3003 Direct test (before gateway) — curl http://localhost:3001/api/projects: HTTP/1.1 200 OK Content-Type: application/json { "service": "Projects Service", "port": 3001, "user": "unknown", "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" } ] } All three services confirmed running. Gateway will proxy to these next.
What just happened?

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.

3
Write the JWT authentication middleware

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);
Test 1 — Request with NO token: GET http://localhost:3000/api/projects HTTP/1.1 401 Unauthorized Content-Type: application/json { "error": "Unauthorized", "message": "No token provided. Include Authorization: Bearer <token> in your request." } Test 2 — Request with INVALID token: GET http://localhost:3000/api/projects Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.invalid.signature HTTP/1.1 401 Unauthorized { "error": "Unauthorized", "message": "Invalid token. Authentication failed." } Test 3 — Request with VALID token (decoded payload): GET http://localhost:3000/api/projects Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... → JWT verified successfully → req.user = { userId: "usr_9f3k2m", email: "priya@apiforge.dev", role: "admin" } → Headers injected: x-user-id, x-user-email, x-user-role → Authorization header removed from forwarded request → Passing to rate limiter...
What just happened?

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.

Try this: Generate a test JWT using 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.

4
Write the rate limiting middleware

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);
Request 1 (within limit): HTTP/1.1 200 OK X-RateLimit-Limit: 100 X-RateLimit-Remaining: 99 X-RateLimit-Reset: 1732012800 Request 47 (within limit): HTTP/1.1 200 OK X-RateLimit-Limit: 100 X-RateLimit-Remaining: 53 X-RateLimit-Reset: 1732012800 Request 101 (limit exceeded): HTTP/1.1 429 Too Many Requests X-RateLimit-Limit: 100 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1732012800 Content-Type: application/json { "error": "Too Many Requests", "message": "Rate limit exceeded. You are allowed 100 requests per minute.", "resetAt": "2024-11-19T12:00:00.000Z" } Redis state (inspect with redis-cli): > GET ratelimit:usr_9f3k2m:28866880 "101" > TTL ratelimit:usr_9f3k2m:28866880 23 ← 23 seconds until this window resets automatically
What just happened?

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.

Try this: Open redis-cli and run 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.

5
Add logging and wire up the proxy routes — complete gateway

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`);
});
Terminal 4 — Gateway startup: [GATEWAY] APIForge API Gateway running on port 3000 [GATEWAY] Routes: /api/projects → :3001 | /api/users → :3002 | /api/analytics → :3003 Live request log (gateway terminal): [GATEWAY] → Proxying to http://localhost:3001/api/projects [GATEWAY] GET /api/projects 200 847B 12.4ms user=usr_9f3k2m [GATEWAY] → Proxying to http://localhost:3002/api/users [GATEWAY] GET /api/users 200 312B 8.1ms user=usr_9f3k2m [GATEWAY] → Proxying to http://localhost:3001/api/projects [GATEWAY] GET /api/projects 401 89B 0.3ms user=unauthenticated End-to-end test — GET /api/projects through the gateway: HTTP/1.1 200 OK X-RateLimit-Limit: 100 X-RateLimit-Remaining: 98 Content-Type: application/json { "service": "Projects Service", "port": 3001, "user": "usr_9f3k2m", "email": "priya@apiforge.dev", "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" } ] } Upstream service down test — GET /api/analytics (port 3003 stopped): HTTP/1.1 502 Bad Gateway { "error": "Bad Gateway", "message": "Upstream service at http://localhost:3003 is unavailable." }
What just happened?

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.

Try this: Stop one of the three services and hit its route through the gateway. You should get a clean 502 Bad Gateway — not a crash or timeout. That error boundary is the gateway protecting your clients from upstream failures.

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.

Without Gateway
Auth logic duplicated in all 3 services
Rate limiting per-service or nonexistent
3 different log formats, 3 different dashboards
Clients need to know 3 different hostnames and ports
A downed service returns an ugly connection error
Adding a 4th service = rebuild auth + rate limit + logging again
With Gateway
Auth in one place — gateway.js line 18
Rate limiting enforced uniformly across all routes
Single structured log stream for all traffic
Clients talk to one URL: api.apiforge.dev
Downed service returns clean 502 with context
Adding a 4th service = one new app.use() line

Final 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
What to build next: This gateway handles routing, auth, rate limiting, and logging. Real production gateways add request/response transformation (rewriting headers, reshaping payloads), circuit breakers (stopping requests to a service that is repeatedly failing), and caching at the gateway layer. Tools like Kong, AWS API Gateway, and Cloudflare API Gateway are all built on these same primitives — understanding what you built here means you can configure and extend any of them.

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?

Up Next
API Case Study
The APIForge team dissects how Stripe's API is architected — versioning strategy, error design, idempotency keys, and the decisions behind one of the most developer-friendly APIs ever built.