Published on

API Gateway Patterns — Rate Limiting, Auth, and Request Transformation at the Edge

Authors

Introduction

An API gateway sits between clients and your services, handling cross-cutting concerns: authentication, rate limiting, request transformation, routing. Built well, a gateway scales your infrastructure. Built poorly, it becomes a bottleneck and single point of failure. We'll design gateways for resilience, understand what belongs at the edge vs in services, and compare commercial options.

Gateway vs Service Mesh Responsibilities

Gateway handles:

  • Authentication (JWT validation, OAuth flow)
  • Rate limiting (per user, per IP)
  • Request aggregation (BFF—Backend For Frontend)
  • API versioning
  • Public API enforcement

Service mesh (Istio, Linkerd) handles:

  • Inter-service communication routing
  • Circuit breaking for service-to-service calls
  • Service-to-service authentication (mTLS)
  • Metrics collection
// Gateway-level responsibilities
class APIGateway {
  async handleRequest(req: Request): Promise<Response> {
    // 1. Rate limit by user/IP
    if (await this.rateLimiter.isRateLimited(req)) {
      return new Response('Too Many Requests', { status: 429 });
    }

    // 2. Authenticate JWT
    const user = await this.validateJWT(req);
    if (!user) {
      return new Response('Unauthorized', { status: 401 });
    }

    // 3. Check authorization
    if (!this.canAccess(user, req.path)) {
      return new Response('Forbidden', { status: 403 });
    }

    // 4. Transform request (API versioning, aggregation)
    const transformed = await this.transformRequest(req, user);

    // 5. Route to service
    const response = await this.routeToService(transformed);

    // 6. Transform response
    return this.transformResponse(response, user);
  }

  private async validateJWT(req: Request): Promise<any | null> {
    const authHeader = req.headers.get('authorization');
    if (!authHeader?.startsWith('Bearer ')) {
      return null;
    }

    const token = authHeader.slice(7);

    try {
      return await this.jwt.verify(token, this.publicKey);
    } catch {
      return null;
    }
  }

  private canAccess(user: any, path: string): boolean {
    // Check user permissions
    const resource = path.split('/')[1]; // /users/123 -> 'users'
    return user.roles.includes(`read:${resource}`);
  }

  private async transformRequest(req: Request, user: any): Promise<Request> {
    // Add user context header
    const headers = new Headers(req.headers);
    headers.set('x-user-id', user.sub);
    headers.set('x-user-roles', user.roles.join(','));

    return new Request(req, { headers });
  }

  private async routeToService(req: Request): Promise<Response> {
    const path = new URL(req.url).pathname;
    const service = this.getServiceForPath(path);
    return await fetch(`${service}${path}`, req);
  }

  private transformResponse(res: Response, user: any): Response {
    // Remove internal headers before returning to client
    const headers = new Headers(res.headers);
    headers.delete('x-internal-trace-id');
    return new Response(res.body, { status: res.status, headers });
  }

  private getServiceForPath(path: string): string {
    const routes: Record<string, string> = {
      users: 'http://user-service:3000',
      products: 'http://product-service:3000',
      orders: 'http://order-service:3000',
    };
    const resource = path.split('/')[1];
    return routes[resource] || 'http://default-service:3000';
  }
}

JWT Validation at Gateway

Validate JWTs at the gateway to reduce per-service overhead.

class GatewayJWTValidator {
  private cachedPublicKeys = new Map<string, string>();
  private keyRefreshInterval = 3600000; // 1 hour

  constructor(private jwksUrl: string) {
    this.refreshPublicKeys();
    setInterval(() => this.refreshPublicKeys(), this.keyRefreshInterval);
  }

  async validateToken(token: string): Promise<any> {
    const decoded = jwt.decode(token, { complete: true });
    if (!decoded) {
      throw new Error('Invalid token format');
    }

    const { header, payload } = decoded;

    // Get public key for this token's key ID
    const publicKey = await this.getPublicKey(header.kid);

    try {
      // Verify signature
      jwt.verify(token, publicKey, {
        algorithms: [header.alg],
        issuer: 'https://auth.example.com',
        audience: 'api.example.com',
      });

      return payload;
    } catch (error) {
      throw new Error(`JWT verification failed: ${error}`);
    }
  }

  private async getPublicKey(kid: string): Promise<string> {
    // Check cache first
    if (this.cachedPublicKeys.has(kid)) {
      return this.cachedPublicKeys.get(kid)!;
    }

    // Fetch JWKS
    const response = await fetch(this.jwksUrl);
    const jwks = await response.json();

    const key = jwks.keys.find((k: any) => k.kid === kid);
    if (!key) {
      throw new Error(`Key ${kid} not found in JWKS`);
    }

    const publicKey = key.x5c[0];
    this.cachedPublicKeys.set(kid, publicKey);

    return publicKey;
  }

  private async refreshPublicKeys(): Promise<void> {
    try {
      const response = await fetch(this.jwksUrl);
      const jwks = await response.json();

      this.cachedPublicKeys.clear();
      for (const key of jwks.keys) {
        this.cachedPublicKeys.set(key.kid, key.x5c[0]);
      }
    } catch (error) {
      console.error('Failed to refresh public keys:', error);
    }
  }
}

Rate Limiting at Gateway

Rate limit before requests reach your services.

class GatewayRateLimiter {
  private redis: any;
  private limits: Record<string, { rps: number; burst: number }> = {
    'free-tier': { rps: 10, burst: 20 },
    'paid-tier': { rps: 100, burst: 200 },
    'enterprise-tier': { rps: 1000, burst: 2000 },
  };

  async isAllowed(userId: string, tier: string): Promise<boolean> {
    const limit = this.limits[tier];
    if (!limit) {
      throw new Error(`Unknown tier: ${tier}`);
    }

    const key = `rate-limit:${userId}`;

    // Use Lua script for atomic increment + check
    const script = `
      local current = redis.call("incr", KEYS[1])
      if current == 1 then
        redis.call("expire", KEYS[1], 1)
      end
      return current <= ARGV[1] and "OK" or "RATE_LIMIT_EXCEEDED"
    `;

    const result = await this.redis.eval(script, 1, key, limit.rps);
    return result === 'OK';
  }

  async limitByIP(ip: string): Promise<boolean> {
    // Separate limit for unauthenticated requests
    const key = `rate-limit:ip:${ip}`;

    const script = `
      local current = redis.call("incr", KEYS[1])
      if current == 1 then
        redis.call("expire", KEYS[1], 60)
      end
      return current <= ARGV[1] and "OK" or "RATE_LIMIT_EXCEEDED"
    `;

    const result = await this.redis.eval(script, 1, key, 1000);
    return result === 'OK';
  }

  async checkTokenBucket(userId: string, tier: string, tokens = 1): Promise<boolean> {
    const limit = this.limits[tier];
    const key = `token-bucket:${userId}`;

    const script = `
      local current = redis.call("get", KEYS[1])
      if not current then
        current = ARGV[1]
      end

      if tonumber(current) >= tonumber(ARGV[2]) then
        return "RATE_LIMIT_EXCEEDED"
      end

      redis.call("incrby", KEYS[1], ARGV[2])
      redis.call("expire", KEYS[1], 60)
      return "OK"
    `;

    const result = await this.redis.eval(
      script,
      1,
      key,
      limit.burst,
      tokens
    );
    return result === 'OK';
  }
}

Request Aggregation (BFF Pattern)

For complex clients, aggregate multiple downstream requests in the gateway.

class BFFGateway {
  async getUserDashboard(userId: string): Promise<DashboardResponse> {
    // Client requests dashboard; gateway aggregates data from multiple services
    const [user, orders, recommendations] = await Promise.all([
      this.userService.getUser(userId),
      this.orderService.getOrders(userId),
      this.recommendationService.getRecommendations(userId),
    ]);

    return {
      user: {
        id: user.id,
        name: user.name,
        email: user.email,
      },
      recentOrders: orders.slice(0, 5).map(o => ({
        id: o.id,
        total: o.total,
        date: o.createdAt,
      })),
      recommendations: recommendations.map(r => ({
        productId: r.productId,
        title: r.title,
        score: r.score,
      })),
    };
  }

  async getProductWithReviews(productId: string): Promise<ProductResponse> {
    // Fetch product and reviews concurrently
    const [product, reviews, seller] = await Promise.all([
      this.productService.getProduct(productId),
      this.reviewService.getReviews(productId),
      this.sellerService.getSeller(productId),
    ]);

    return {
      ...product,
      reviews: reviews.map(r => ({
        author: r.authorId,
        rating: r.rating,
        text: r.text,
      })),
      seller: {
        name: seller.name,
        rating: seller.rating,
      },
    };
  }

  private userService = {
    getUser: async (userId: string) => ({}),
  };

  private orderService = {
    getOrders: async (userId: string) => [],
  };

  private recommendationService = {
    getRecommendations: async (userId: string) => [],
  };

  private productService = {
    getProduct: async (productId: string) => ({}),
  };

  private reviewService = {
    getReviews: async (productId: string) => [],
  };

  private sellerService = {
    getSeller: async (productId: string) => ({}),
  };
}

interface DashboardResponse {
  user: any;
  recentOrders: any[];
  recommendations: any[];
}

interface ProductResponse {
  reviews: any[];
  seller: any;
}

API Versioning at Gateway

Version your API without requiring service changes.

class APIVersioningGateway {
  async handleRequest(req: Request): Promise<Response> {
    const url = new URL(req.url);
    const version = this.extractVersion(url.pathname);

    let path = url.pathname;

    // Transform request based on version
    if (version === 'v1') {
      const body = await req.json();
      const transformedBody = this.transformV1RequestToV2(body);
      req = new Request(req, {
        body: JSON.stringify(transformedBody),
        method: 'POST',
      });
      path = path.replace('/v1/', '/v2/');
    }

    // Route to service
    const response = await fetch(`http://api-service:3000${path}`, req);

    // Transform response based on version
    if (version === 'v1') {
      const body = await response.json();
      const transformedBody = this.transformV2ResponseToV1(body);
      return Response.json(transformedBody);
    }

    return response;
  }

  private extractVersion(pathname: string): string {
    const match = pathname.match(/\/(v\d+)\//);
    return match ? match[1] : 'v2';
  }

  private transformV1RequestToV2(v1Body: any): any {
    return {
      customerId: v1Body.id,
      chargeAmount: v1Body.amount,
    };
  }

  private transformV2ResponseToV1(v2Body: any): any {
    return {
      transaction_id: v2Body.chargeId,
      state: v2Body.status === 'completed' ? 'success' : 'pending',
    };
  }
}

Gateway-Level Circuit Breaking

Protect your gateway and downstream services.

class CircuitBreakerGateway {
  private breakers = new Map<string, CircuitBreaker>();

  async routeToService(serviceName: string, request: Request): Promise<Response> {
    const breaker = this.getOrCreateBreaker(serviceName);
    const serviceUrl = this.serviceRegistry.get(serviceName);

    return await breaker.execute(async () => {
      try {
        const response = await fetch(serviceUrl, request);

        if (!response.ok && response.status >= 500) {
          throw new Error(`Service error: ${response.status}`);
        }

        return response;
      } catch (error) {
        console.error(`Request to ${serviceName} failed:`, error);
        throw error;
      }
    });
  }

  private getOrCreateBreaker(serviceName: string): CircuitBreaker {
    if (!this.breakers.has(serviceName)) {
      this.breakers.set(
        serviceName,
        new CircuitBreaker({
          failureThreshold: 5,
          resetTimeout: 60000,
          name: serviceName,
        })
      );
    }
    return this.breakers.get(serviceName)!;
  }

  private serviceRegistry = new Map<string, string>([
    ['user-service', 'http://user-service:3000'],
    ['order-service', 'http://order-service:3000'],
  ]);
}

class CircuitBreaker {
  private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
  private failureCount = 0;
  private lastFailureTime = 0;
  private successCount = 0;

  constructor(
    private options: {
      failureThreshold: number;
      resetTimeout: number;
      name: string;
    }
  ) {}

  async execute<T>(fn: () => Promise<T>): Promise<T> {
    if (this.state === 'OPEN') {
      if (Date.now() - this.lastFailureTime > this.options.resetTimeout) {
        this.state = 'HALF_OPEN';
        this.successCount = 0;
      } else {
        throw new Error(`Circuit breaker OPEN for ${this.options.name}`);
      }
    }

    try {
      const result = await fn();

      if (this.state === 'HALF_OPEN') {
        this.successCount++;
        if (this.successCount >= 2) {
          this.state = 'CLOSED';
          this.failureCount = 0;
        }
      }

      return result;
    } catch (error) {
      this.failureCount++;
      this.lastFailureTime = Date.now();

      if (this.failureCount >= this.options.failureThreshold) {
        this.state = 'OPEN';
      }

      throw error;
    }
  }
}

Kong vs AWS API Gateway vs Custom Express Gateway

Kong is open-source with plugin ecosystem. AWS API Gateway is fully managed. Custom Express is simple and owned.

const kongConfig = {
  services: [
    {
      name: 'user-service',
      url: 'http://user-service:3000',
      routes: [
        {
          paths: ['/users'],
          methods: ['GET', 'POST'],
        },
      ],
    },
  ],
  plugins: [
    {
      name: 'rate-limiting',
      config: {
        minute: 100,
        hour: 10000,
      },
    },
    {
      name: 'jwt',
      config: {
        secret: 'your-secret-key',
      },
    },
    {
      name: 'cors',
      config: {
        origins: ['https://example.com'],
      },
    },
  ],
};

// Custom Express gateway
import express from 'express';

class CustomGateway {
  private app = express();

  setupMiddleware(): void {
    // Authentication
    this.app.use((req, res, next) => {
      const token = req.headers.authorization?.replace('Bearer ', '');
      if (token) {
        try {
          (req as any).user = this.jwt.verify(token, this.publicKey);
        } catch {
          return res.status(401).json({ error: 'Unauthorized' });
        }
      }
      next();
    });

    // Rate limiting
    this.app.use((req, res, next) => {
      const userId = (req as any).user?.id || req.ip;
      this.rateLimiter.checkLimit(userId).then(allowed => {
        if (allowed) {
          next();
        } else {
          res.status(429).json({ error: 'Rate limited' });
        }
      });
    });

    // Routes
    this.app.use('/users', this.createProxy('http://user-service:3000'));
    this.app.use('/orders', this.createProxy('http://order-service:3000'));
  }

  private createProxy(target: string): any {
    return async (req: any, res: any) => {
      const response = await fetch(`${target}${req.path}`, {
        method: req.method,
        headers: req.headers,
        body: req.body,
      });
      return response;
    };
  }

  private jwt: any;
  private publicKey = '';
  private rateLimiter: any;
}

Checklist

  • Validate JWTs at gateway to reduce per-service overhead
  • Implement rate limiting per user, per tier, and per IP
  • Use circuit breakers to prevent cascading failures
  • Aggregate requests for mobile clients (BFF pattern)
  • Version APIs at the gateway, not services
  • Log all requests with correlation IDs for debugging
  • Monitor gateway latency and error rates
  • Test gateway failover and resilience
  • Use service discovery for dynamic routing
  • Cache responses when appropriate

Conclusion

API gateways are force multipliers for distributed systems—they centralize cross-cutting concerns and reduce work per service. Keep them simple and stateless; add a second instance for high availability. Choose between managed (AWS API Gateway) for simplicity or Kong for flexibility. Don't put business logic in your gateway; that's what services are for.