All posts
26 Aug 2026

Building a Lightweight API Gateway in Node.js: Routing, Auth, and Rate Limiting at the Edge

Learn how to build a high-performance, custom API Gateway in Node.js using Fastify to handle centralized JWT validation, upstream routing, and sliding-window rate limiting.

Building a Lightweight API Gateway in Node.js: Routing, Auth, and Rate Limiting at the Edge

Microservices architectures offer incredible scalability and team autonomy, but they introduce a messy client-facing reality. When a frontend application needs to fetch user profiles, process payments, and load inventory, should it know the exact IP addresses and internal ports of three different microservices?

Of course not. That is where an API Gateway comes in.

The API Gateway acts as the single entry point for all client requests. Instead of exposing your internal microservices directly to the wild, the gateway handles cross-cutting concerns like:

  1. Request Routing: Forwarding requests to the correct downstream service.
  2. Authentication & Authorization: Validating JWTs or API keys before traffic hits your internal network.
  3. Rate Limiting: Protecting downstream services from abuse, DDoS, or runaway client loops.
  4. Request/Response Transformation: Injecting headers, stripping sensitive fields, or normalizing payloads.

While robust open-source gateways like Kong, Traefik, or Tyk exist, building a custom gateway gives you absolute control over your middleware pipeline, enables rapid prototyping of custom business logic, and keeps your stack unified if your team is already writing Node.js.

In this practical guide, we will build a high-performance, lightweight API Gateway in Node.js using Fastify and http-proxy-middleware.


Why Fastify for an API Gateway?

When building a gateway, performance is paramount. Every millisecond your gateway spends processing a request adds latency to every downstream call.

While Express is the veteran choice, Fastify is built from the ground up to be extremely fast and low-overhead. It handles asynchronous hooks seamlessly, has a brilliant schema-based validation engine, and consumes significantly fewer resources under high concurrency.

Project Architecture

Our custom gateway will sit between the client and two hypothetical microservices:

  • User Service: http://localhost:4001
  • Order Service: http://localhost:4002
code
[ Client ] ---> [ API Gateway (Port 3000) ]
                     │
         ┌───────────┴───────────┐
         ▼                       ▼
[ User Service (4001) ]  [ Order Service (4002) ]

Prerequisites and Dependencies

Initialize a new Node.js project and install the necessary dependencies:

npm init -y
npm install fastify @fastify/http-proxy jsonwebtoken redis ioredis
npm install --save-dev nodemon
  • fastify: Our core web framework.
  • @fastify/http-proxy: A robust plugin for proxying requests to upstream servers.
  • jsonwebtoken: For verifying JWT signatures.
  • ioredis: A high-performance Redis client for our rate limiter.

Step 1: Setting Up the Gateway Core and Routing

Let’s create our entry point server.js. We will configure Fastify and set up our basic routing map.

const Fastify = require('fastify');
const proxy = require('@fastify/http-proxy');

async function buildGateway() {
  const app = Fastify({ logger: true });

  // Route traffic to User Service
  await app.register(proxy, {
    upstream: 'http://localhost:4001',
    prefix: '/users',
    rewritePrefix: '/users',
    httpMethods: ['GET', 'POST', 'PUT', 'DELETE']
  });

  // Route traffic to Order Service
  await app.register(proxy, {
    upstream: 'http://localhost:4002',
    prefix: '/orders',
    rewritePrefix: '/orders',
    httpMethods: ['GET', 'POST', 'PUT', 'DELETE']
  });

  // Health check endpoint
  app.get('/health', async (request, reply) => {
    return { status: 'gateway-healthy', timestamp: Date.now() };
  });

  return app;
}

async function start() {
  try {
    const gateway = await buildGateway();
    await gateway.listen({ port: 3000, host: '0.0.0.0' });
    console.log('API Gateway running on port 3000');
  } catch (err) {
    console.error(err);
    process.exit(1);
  }
}

start();

This simple setup instantly gives us a reverse proxy. Any request to http://localhost:3000/users/... is securely tunneled to http://localhost:4001/users/....


Step 2: Implementing Centralized Authentication

Instead of making every microservice validate JWTs against an auth database or decode secrets independently, our API Gateway will handle authentication at the edge.

We will write a Fastify onRequest hook that intercepts incoming requests, verifies the JWT, and injects user claims into custom headers (x-user-id, x-user-role) before forwarding the payload downstream.

Create a middleware file auth.middleware.js:

const jwt = require('jsonwebtoken');

const JWT_SECRET = process.env.JWT_SECRET || 'super-secret-gateway-key';

// Public routes that don't require authentication
const PUBLIC_ROUTES = ['/health', '/users/login', '/users/register'];

async function authenticateRequest(request, reply) {
  // Skip auth for public routes
  if (PUBLIC_ROUTES.some(route => request.url.startsWith(route))) {
    return;
  }

  const authHeader = request.headers['authorization'];
  
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    reply.code(401).send({ error: 'Unauthorized: Missing or malformed token' });
    return;
  }

  const token = authHeader.split(' ')[1];

  try {
    const decoded = jwt.verify(token, JWT_SECRET);
    
    // Inject verified user metadata into headers for downstream microservices
    request.headers['x-user-id'] = decoded.sub;
    request.headers['x-user-role'] = decoded.role;
    
    // Optional: Strip the original Authorization header if internal services trust x-user-id
    // delete request.headers['authorization'];

  } catch (err) {
    reply.code(403);
    return { error: 'Forbidden: Invalid or expired token', details: err.message };
  }
}

module.exports = authenticateRequest;

Register this hook globally in your server.js file:

const authenticateRequest = require('./auth.middleware');

// Inside buildGateway()
app.addHook('onRequest', authenticateRequest);

Now, downstream microservices do not need complex JWT parsing libraries. They simply read req.headers['x-user-id'] and trust that the gateway has already validated the token.


Step 3: Implementing Distributed Rate Limiting with Redis

Rate limiting at the edge protects your infrastructure from brute-force attacks and noisy neighbors. We will implement a sliding-window rate limiter using Redis and ioredis to track request counts per IP address.

Create ratelimit.middleware.js:

const Redis = require('ioredis');
const redis = new Redis({ host: 'localhost', port: 6379 });

const RATE_LIMIT_MAX = 100; // Max requests
const RATE_LIMIT_WINDOW = 60; // Window in seconds

async function rateLimiter(request, reply) {
  // Skip health checks from rate limiting
  if (request.url === '/health') return;

  const clientIp = request.ip;
  const currentTimestamp = Math.floor(Date.now() / 1000);
  const windowKey = `ratelimit:${clientIp}:${Math.floor(currentTimestamp / RATE_LIMIT_WINDOW)}`;

  try {
    const multi = redis.multi();
    multi.incr(windowKey);
    multi.expire(windowKey, RATE_LIMIT_WINDOW);
    
    const results = await multi.exec();
    const requestCount = results[0][1];

    // Set rate limit headers
    reply.header('X-RateLimit-Limit', RATE_LIMIT_MAX);
    reply.header('X-RateLimit-Remaining', Math.max(0, RATE_LIMIT_MAX - requestCount));

    if (requestCount > RATE_LIMIT_MAX) {
      reply.code(429).send({
        error: 'Too Many Requests',
        message: 'Rate limit exceeded. Please try again later.'
      });
    }
  } catch (err) {
    request.log.error('Rate limiter error: ', err);
    // Fail open if Redis goes down, rather than blocking all traffic
  }
}

module.exports = rateLimiter;

Register the rate limiter hook right alongside your authentication hook:

const rateLimiter = require('./ratelimit.middleware');

// Inside buildGateway()
app.addHook('onRequest', rateLimiter);
app.addHook('onRequest', authenticateRequest);

Step 4: Request Transformation and Logging

An API Gateway is also a fantastic place to standardize logging, tracing, and request transformation. Let’s add a correlation ID to every incoming request so you can trace a user action across multiple microservices.

Add a correlation ID hook in server.js:

const { randomUUID } = require('crypto');

app.addHook('onRequest', async (request, reply) => {
  const correlationId = request.headers['x-correlation-id'] || randomUUID();
  request.headers['x-correlation-id'] = correlationId;
  reply.header('x-correlation-id', correlationId);
});

When the proxy forwards the request to the User Service, the x-correlation-id header travels with it. If the User Service logs errors, your log aggregation tool (like ELK, Datadog, or Grafana Loki) can filter by this correlation ID to trace the exact lifecycle of the request.


Complete Gateway Implementation

Here is how your fully assembled server.js comes together:

const Fastify = require('fastify');
const proxy = require('@fastify/http-proxy');
const { randomUUID } = require('crypto');

const authenticateRequest = require('./auth.middleware');
const rateLimiter = require('./ratelimit.middleware');

async function buildGateway() {
  const app = Fastify({ logger: true });

  // 1. Correlation ID Middleware
  app.addHook('onRequest', async (request, reply) => {
    const correlationId = request.headers['x-correlation-id'] || randomUUID();
    request.headers['x-correlation-id'] = correlationId;
    reply.header('x-correlation-id', correlationId);
  });

  // 2. Rate Limiting Middleware
  app.addHook('onRequest', rateLimiter);

  // 3. Authentication Middleware
  app.addHook('onRequest', authenticateRequest);

  // 4. Upstream Proxy Routes
  await app.register(proxy, {
    upstream: 'http://localhost:4001',
    prefix: '/users',
    rewritePrefix: '/users',
  });

  await app.register(proxy, {
    upstream: 'http://localhost:4002',
    prefix: '/orders',
    rewritePrefix: '/orders',
  });

  // Health Check
  app.get('/health', async () => ({ status: 'healthy', uptime: process.uptime() }));

  return app;
}

async function start() {
  try {
    const app = await buildGateway();
    await app.listen({ port: 3000, host: '0.0.0.0' });
    console.log('🚀 Custom API Gateway operational on port 3000');
  } catch (err) {
    console.error('Failed to start gateway:', err);
    process.exit(1);
  }
}

start();

Best Practices & Production Considerations

If you plan to run a custom Node.js API Gateway in production, keep these operational tips in mind:

Fail Open vs. Fail Closed: When dealing with Redis rate-limiting or external identity providers, decide how your gateway handles outages. For rate limiting, it’s usually better to fail open (allow traffic if Redis drops) than to take down your entire platform. For authentication, always fail closed.

  • Horizontal Scaling: API gateways are stateless (especially when backed by Redis). Deploy multiple instances behind a load balancer (like AWS ALB or Nginx) to ensure high availability.
  • Keep-Alive Connections: Ensure your upstream proxy configuration uses persistent HTTP keep-alive agents to avoid TCP handshake overhead between the gateway and your microservices.
  • Circuit Breaking: Integrate libraries like opossum if your upstream microservices become unstable, allowing the gateway to fail fast rather than hanging waiting for a dead service.

Conclusion

Building a custom API Gateway in Node.js gives you profound architectural flexibility. With Fastify and a few clean middleware hooks, you can centralize JWT validation, enforce Redis-backed rate limiting, and route traffic to downstream microservices with minimal latency overhead.

While heavyweight enterprise gateways have their place, writing your own gateway empowers your team to customize edge behavior precisely around your product’s unique needs.

More posts