DEV Community

Young Gao
Young Gao

Posted on

Building a Zero-Trust API Gateway with Cloudflare Workers in 2026

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)
Enter fullscreen mode Exit fullscreen mode

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' };
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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,
  });
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  },
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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 }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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.

Ko-fi

Top comments (0)