API Gateway Patterns — Rate Limiting, Auth, and Request Transformation at the Edge
Advertisement
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.
Advertisement