API Gateway Patterns
at the Edge
Rate limiting, authentication, request transformation, response caching, and analyticsโbuilding a production API gateway on Cloudflare Workers.
Every API needs a gateway. Authentication, rate limiting, transformation, cachingโthese concerns shouldn't live in your business logic. They belong at the edge, handled before requests hit your services.
This is the API gateway architecture running in production, processing 2+ million requests monthly with sub-50ms overhead.
The Pipeline
Pattern 1: Authentication Middleware
Validate API keys and JWTs before anything else:
interface AuthContext {
userId: string;
tenantId: string;
scopes: string[];
plan: 'free' | 'pro' | 'enterprise';
}
export async function authenticate(
request: Request,
env: Env
): Promise<AuthContext | Response> {
// Check for API key
const apiKey = request.headers.get('X-API-Key');
if (apiKey) {
return await validateApiKey(apiKey, env);
}
// Check for Bearer token
const authHeader = request.headers.get('Authorization');
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7);
return await validateJWT(token, env);
}
return new Response(JSON.stringify({
error: 'unauthorized',
message: 'Missing API key or Bearer token'
}), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
async function validateApiKey(key: string, env: Env) {
// Hash key for lookup (never store plain keys)
const keyHash = await hashKey(key);
// Check cache first
const cached = await env.KV.get(`apikey:${keyHash}`, 'json');
if (cached) return cached as AuthContext;
// Query database
const result = await env.DB.prepare(
`SELECT user_id, tenant_id, scopes, plan FROM api_keys WHERE key_hash = ?`
).bind(keyHash).first();
if (!result) {
return new Response('Invalid API key', { status: 401 });
}
const context: AuthContext = {
userId: result.user_id,
tenantId: result.tenant_id,
scopes: JSON.parse(result.scopes),
plan: result.plan
};
// Cache for 5 minutes
await env.KV.put(`apikey:${keyHash}`, JSON.stringify(context), {
expirationTtl: 300
});
return context;
}
Pattern 2: Rate Limiting
Protect your backend with sliding window rate limiting:
export async function rateLimit(
auth: AuthContext,
env: Env
): Promise<Response | null> {
const limiter = env.RATE_LIMITERS.get(
env.RATE_LIMITERS.idFromName(auth.tenantId)
);
const result = await limiter.fetch('http://limiter/check', {
method: 'POST',
body: JSON.stringify({ plan: auth.plan })
}).then(r => r.json());
if (!result.allowed) {
return new Response(JSON.stringify({
error: 'rate_limit_exceeded',
message: `Rate limit exceeded. Retry after ${result.retryAfter}s`,
limit: result.limit,
remaining: 0,
reset: result.resetAt
}), {
status: 429,
headers: {
'Content-Type': 'application/json',
'X-RateLimit-Limit': result.limit.toString(),
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': result.resetAt.toString(),
'Retry-After': result.retryAfter.toString()
}
});
}
return null; // Allowed - continue
}
Pattern 3: Request Transformation
Normalize requests before they hit your backend:
export function transformRequest(
request: Request,
auth: AuthContext
): Request {
const url = new URL(request.url);
// Version routing: /v1/users โ /api/users
const path = url.pathname.replace(/^\/v\d+/, '/api');
// Build new headers
const headers = new Headers(request.headers);
// Inject auth context for backend
headers.set('X-User-ID', auth.userId);
headers.set('X-Tenant-ID', auth.tenantId);
headers.set('X-User-Plan', auth.plan);
headers.set('X-User-Scopes', auth.scopes.join(','));
// Add request ID for tracing
const requestId = crypto.randomUUID();
headers.set('X-Request-ID', requestId);
// Strip sensitive headers
headers.delete('Authorization');
headers.delete('X-API-Key');
return new Request(
`${env.BACKEND_URL}${path}${url.search}`,
{
method: request.method,
headers,
body: request.body
}
);
}
Pattern 4: Response Caching
Cache GET responses at the edge for instant responses:
export async function withCache(
request: Request,
auth: AuthContext,
handler: () => Promise<Response>,
env: Env
): Promise<Response> {
// Only cache GET requests
if (request.method !== 'GET') {
return await handler();
}
// Generate cache key (include tenant for isolation)
const url = new URL(request.url);
const cacheKey = `cache:${auth.tenantId}:${url.pathname}${url.search}`;
// Check cache
const cached = await env.KV.get(cacheKey, 'text');
if (cached) {
const { body, headers, status } = JSON.parse(cached);
return new Response(body, {
status,
headers: { ...headers, 'X-Cache': 'HIT' }
});
}
// Cache miss - call handler
const response = await handler();
// Only cache successful responses
if (response.ok) {
const body = await response.text();
const headers = Object.fromEntries(response.headers);
await env.KV.put(cacheKey, JSON.stringify({
body,
headers,
status: response.status
}), { expirationTtl: 60 }); // 1 minute TTL
return new Response(body, {
status: response.status,
headers: { ...headers, 'X-Cache': 'MISS' }
});
}
return response;
}
Pattern 5: Analytics & Logging
Track every request for debugging and billing:
export function logRequest(
request: Request,
response: Response,
auth: AuthContext,
startTime: number,
ctx: ExecutionContext
) {
const log = {
timestamp: new Date().toISOString(),
requestId: request.headers.get('X-Request-ID'),
method: request.method,
path: new URL(request.url).pathname,
status: response.status,
latencyMs: Date.now() - startTime,
tenantId: auth.tenantId,
userId: auth.userId,
plan: auth.plan,
cached: response.headers.get('X-Cache') === 'HIT',
userAgent: request.headers.get('User-Agent'),
country: request.cf?.country,
colo: request.cf?.colo
};
// Fire and forget - don't block response
ctx.waitUntil(
Promise.all([
// Send to analytics service
sendToAnalytics(log),
// Increment usage counter
incrementUsage(auth.tenantId, env)
])
);
}
Putting It All Together
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const startTime = Date.now();
try {
// 1. Authenticate
const authResult = await authenticate(request, env);
if (authResult instanceof Response) return authResult;
const auth = authResult;
// 2. Rate limit
const rateLimitResponse = await rateLimit(auth, env);
if (rateLimitResponse) return rateLimitResponse;
// 3. Check authorization (scopes)
const scopeCheck = checkScopes(request, auth);
if (scopeCheck) return scopeCheck;
// 4. Transform & route
const transformedRequest = transformRequest(request, auth);
// 5. Execute with caching
const response = await withCache(
request,
auth,
() => fetch(transformedRequest),
env
);
// 6. Log (async, non-blocking)
logRequest(request, response, auth, startTime, ctx);
// 7. Add standard headers
const headers = new Headers(response.headers);
headers.set('X-Response-Time', `${Date.now() - startTime}ms`);
return new Response(response.body, {
status: response.status,
headers
});
} catch (error) {
return new Response(JSON.stringify({
error: 'internal_error',
message: 'An unexpected error occurred'
}), { status: 500 });
}
}
};
ctx.waitUntil() for non-critical operations like logging and analytics. This lets the response return immediately while background work completes.Gateway Checklist
- API key and JWT authentication
- Per-tenant rate limiting with Durable Objects
- Request transformation and header injection
- Response caching with tenant isolation
- Request/response logging for debugging
- Usage tracking for billing
- Proper error responses with retry hints
- Request ID propagation for tracing
An API gateway isn't optional infrastructureโit's the foundation of a secure, scalable API. Build it once, apply it everywhere, and your backend services can focus on business logic.