Traditional API gateways are expensive, complex, and often become single points of failure. Here's how to build a production-grade zero-trust API gateway using Cloudflare Workers — serverless, globally distributed, and practically free at scale.
Why Cloudflare Workers?
| Feature | Traditional Gateway | CF Workers |
|---|---|---|
| Cold start | 50-500ms | 0ms (always warm) |
| Global distribution | $$$ (multi-region) | Free (300+ PoPs) |
| Pricing | $200-2000/mo | $5/mo (10M requests) |
| TLS termination | Manual cert mgmt | Automatic |
| DDoS protection | Additional cost | Built-in |
Architecture Overview
Client → Cloudflare Edge (300+ PoPs)
↓
[Worker: Auth + Rate Limit + Transform]
↓
[KV: Sessions + Config] [D1: Audit Logs]
↓
Origin API (your backend)
The Worker sits at the edge, handling authentication, rate limiting, request transformation, and audit logging before forwarding to your origin.
Step 1: JWT Validation at the Edge
import { verify } from '@tsndr/cloudflare-worker-jwt';
interface Env {
JWT_SECRET: string;
RATE_LIMIT_KV: KVNamespace;
AUDIT_DB: D1Database;
}
async function validateToken(
request: Request,
env: Env
): Promise<{ valid: boolean; claims?: any; error?: string }> {
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return { valid: false, error: 'Missing or invalid Authorization header' };
}
const token = authHeader.slice(7);
try {
const isValid = await verify(token, env.JWT_SECRET);
if (!isValid) {
return { valid: false, error: 'Invalid token signature' };
}
// Decode payload (verify already checked signature)
const payload = JSON.parse(
atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))
);
// Check expiration
if (payload.exp && payload.exp < Date.now() / 1000) {
return { valid: false, error: 'Token expired' };
}
return { valid: true, claims: payload };
} catch (e) {
return { valid: false, error: 'Token validation failed' };
}
}
Step 2: Sliding Window Rate Limiter
async function checkRateLimit(
key: string,
limit: number,
windowSec: number,
kv: KVNamespace
): Promise<{ allowed: boolean; remaining: number; resetAt: number }> {
const now = Math.floor(Date.now() / 1000);
const windowKey = `rl:${key}:${Math.floor(now / windowSec)}`;
const current = parseInt(await kv.get(windowKey) || '0');
if (current >= limit) {
return {
allowed: false,
remaining: 0,
resetAt: (Math.floor(now / windowSec) + 1) * windowSec,
};
}
// Increment counter with TTL
await kv.put(windowKey, String(current + 1), {
expirationTtl: windowSec * 2,
});
return {
allowed: true,
remaining: limit - current - 1,
resetAt: (Math.floor(now / windowSec) + 1) * windowSec,
};
}
Step 3: Request Transformation & Routing
async function transformAndRoute(
request: Request,
claims: any,
env: Env
): Promise<Response> {
const url = new URL(request.url);
// Route based on path prefix
const routes: Record<string, string> = {
'/api/v1/users': 'https://users-service.internal',
'/api/v1/orders': 'https://orders-service.internal',
'/api/v1/billing': 'https://billing-service.internal',
};
const matchedRoute = Object.entries(routes).find(
([prefix]) => url.pathname.startsWith(prefix)
);
if (!matchedRoute) {
return new Response(JSON.stringify({ error: 'Route not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
const [prefix, origin] = matchedRoute;
const targetUrl = `${origin}${url.pathname.slice(prefix.length)}${url.search}`;
// Forward with identity headers (zero-trust)
const headers = new Headers(request.headers);
headers.set('X-User-ID', claims.sub);
headers.set('X-User-Role', claims.role || 'user');
headers.set('X-Request-ID', crypto.randomUUID());
headers.delete('Authorization'); // Don't forward JWT to origin
return fetch(targetUrl, {
method: request.method,
headers,
body: request.body,
});
}
Step 4: Audit Logging with D1
async function logRequest(
request: Request,
response: Response,
claims: any,
env: Env,
startTime: number
): Promise<void> {
const duration = Date.now() - startTime;
const url = new URL(request.url);
try {
await env.AUDIT_DB.prepare(
`INSERT INTO audit_logs (timestamp, user_id, method, path, status, duration_ms, ip, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).bind(
new Date().toISOString(),
claims?.sub || 'anonymous',
request.method,
url.pathname,
response.status,
duration,
request.headers.get('CF-Connecting-IP') || 'unknown',
request.headers.get('User-Agent')?.slice(0, 200) || 'unknown'
).run();
} catch (e) {
// Don't fail the request if logging fails
console.error('Audit log failed:', e);
}
}
Step 5: Putting It All Together
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const startTime = Date.now();
// CORS preflight
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Authorization, Content-Type',
'Access-Control-Max-Age': '86400',
},
});
}
// Health check (no auth needed)
if (new URL(request.url).pathname === '/health') {
return Response.json({ status: 'ok', edge: request.cf?.colo });
}
// 1. Authenticate
const auth = await validateToken(request, env);
if (!auth.valid) {
return Response.json({ error: auth.error }, { status: 401 });
}
// 2. Rate limit (per user)
const rateLimit = await checkRateLimit(
auth.claims.sub, 100, 60, env.RATE_LIMIT_KV
);
if (!rateLimit.allowed) {
return Response.json(
{ error: 'Rate limit exceeded' },
{
status: 429,
headers: {
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': String(rateLimit.resetAt),
'Retry-After': String(rateLimit.resetAt - Math.floor(Date.now() / 1000)),
},
}
);
}
// 3. Route and transform
const response = await transformAndRoute(request, auth.claims, env);
// 4. Audit log (non-blocking)
const ctx = { waitUntil: (p: Promise<any>) => p };
ctx.waitUntil(logRequest(request, response, auth.claims, env, startTime));
// Add security + rate limit headers
const finalResponse = new Response(response.body, response);
finalResponse.headers.set('X-RateLimit-Remaining', String(rateLimit.remaining));
finalResponse.headers.set('X-Request-ID', crypto.randomUUID());
finalResponse.headers.set('X-Response-Time', `${Date.now() - startTime}ms`);
return finalResponse;
},
};
Deployment
# Create KV namespace for rate limiting
wrangler kv:namespace create RATE_LIMIT_KV
# Create D1 database for audit logs
wrangler d1 create gateway-audit
# Create the audit_logs table
wrangler d1 execute gateway-audit --command "CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
user_id TEXT NOT NULL,
method TEXT NOT NULL,
path TEXT NOT NULL,
status INTEGER NOT NULL,
duration_ms INTEGER NOT NULL,
ip TEXT,
user_agent TEXT
);"
# Deploy
wrangler deploy
Production Hardening
1. Add IP Allowlisting
const ALLOWED_IPS = new Set(['203.0.113.0/24', '198.51.100.0/24']);
function isIPAllowed(request: Request): boolean {
const ip = request.headers.get('CF-Connecting-IP');
if (!ip) return false;
// In production, use a proper CIDR matching library
return ALLOWED_IPS.has(ip);
}
2. Request Size Limits
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB
if (request.headers.get('content-length')) {
const size = parseInt(request.headers.get('content-length')!);
if (size > MAX_BODY_SIZE) {
return Response.json(
{ error: 'Request body too large' },
{ status: 413 }
);
}
}
3. Circuit Breaker Pattern
Track origin failures in KV and open the circuit when error rate exceeds threshold:
async function checkCircuit(origin: string, kv: KVNamespace): Promise<boolean> {
const failures = parseInt(await kv.get(`cb:${origin}:failures`) || '0');
const lastCheck = parseInt(await kv.get(`cb:${origin}:last`) || '0');
const now = Date.now();
// Reset after 30 seconds
if (now - lastCheck > 30000) return true;
// Open circuit at 5 failures
return failures < 5;
}
Cost Comparison
For 50M requests/month:
| Solution | Monthly Cost |
|---|---|
| AWS API Gateway | ~$175 |
| Kong Enterprise | ~$1,000+ |
| Apigee | ~$2,500+ |
| CF Workers + KV + D1 | ~$10 |
The Workers approach is 10-250x cheaper while being globally distributed with zero cold starts.
Conclusion
A Cloudflare Workers-based API gateway gives you:
- Zero-trust authentication at the edge
- Global rate limiting via KV
- Audit logging with D1
- Sub-millisecond overhead (no cold starts)
- $5-10/month for most workloads
The entire gateway is ~150 lines of TypeScript. No Kubernetes, no load balancers, no certificate management.
Building something with Cloudflare Workers? Share your architecture in the comments!
Found this useful? Follow me for more production backend content.
Top comments (0)