Multi-Tenant Webhook Routing and Rate Limiting: Cloudflare Workers, Redis, and n8n

Running a multi-tenant automation architecture on n8n introduces a severe operational vulnerability: the noisy neighbor problem. When multiple external clients, internal departments, or customer webhooks hit a single public webhook endpoint, an unexpected traffic spike or malicious loop from a single tenant can exhaust your Redis BullQueue and starve worker processes, dropping critical events from all other tenants.

Resolving this requires decoupling ingestion rate limiting from the core n8n application process. By deploying a Cloudflare Worker at the edge backed by a distributed Redis counter, traffic is authenticated, metered, and rate-limited per tenant before it ever reaches the n8n cluster network.

Prerequisite: Ensure your underlying execution cluster is configured for distributed workloads. Review our runbook on Deploying n8n in Queue Mode with Docker Composeand configure your host allocations via our Hardware Sizing and PostgreSQL Tuning Guide.

Edge Ingress Architecture Diagram

<!-- Source Inbound -->
<rect x="20" y="50" width="170" height="70" rx="8" fill="#1E293B" stroke="#38BDF8" stroke-width="1.5"/>
<text x="105" y="80" fill="#F8FAFC" font-size="13" font-weight="600" text-anchor="middle">Inbound Webhooks</text>
<text x="105" y="100" fill="#38BDF8" font-size="11" text-anchor="middle">X-Tenant-Key Header</text>

<!-- Arrow 1 -> 2 -->
<line x1="190" y1="85" x2="260" y2="85" stroke="#64748B" stroke-width="2" marker-end="url(#arr-gray)"/>

<!-- Cloudflare Edge Worker -->
<rect x="260" y="45" width="200" height="80" rx="8" fill="#0F172A" stroke="#F59E0B" stroke-width="1.5"/>
<text x="360" y="75" fill="#FBBF24" font-size="13" font-weight="700" text-anchor="middle">Cloudflare Worker (Edge)</text>
<text x="360" y="95" fill="#94A3B8" font-size="11" text-anchor="middle">Auth &amp; Sliding Window Rate Limit</text>

<!-- Arrow to Upstash/Redis -->
<line x1="360" y1="125" x2="360" y2="190" stroke="#64748B" stroke-width="2" marker-end="url(#arr-gray)"/>

<!-- Redis Token Bucket -->
<rect x="260" y="190" width="200" height="65" rx="8" fill="#1E293B" stroke="#A855F7" stroke-width="1.5"/>
<text x="360" y="218" fill="#C084FC" font-size="13" font-weight="600" text-anchor="middle">Upstash / Redis REST</text>
<text x="360" y="238" fill="#94A3B8" font-size="11" text-anchor="middle">Atomic Token Bucket (TTL: 60s)</text>

<!-- Path: Rejected 429 -->
<line x1="460" y1="65" x2="560" y2="65" stroke="#EF4444" stroke-width="2" marker-end="url(#arr-red)"/>
<rect x="560" y="40" width="220" height="50" rx="8" fill="#450A0A" stroke="#EF4444" stroke-width="1.2"/>
<text x="670" y="65" fill="#FCA5A5" font-size="12" font-weight="600" text-anchor="middle">HTTP 429: Rate Limit Exceeded</text>
<text x="670" y="80" fill="#EF4444" font-size="10" text-anchor="middle">Blocked before reaching cluster</text>

<!-- Path: Forward to n8n Cluster -->
<path d="M 460 100 L 520 100 L 520 160 L 560 160" fill="none" stroke="#10B981" stroke-width="2" marker-end="url(#arr-green)"/>
<rect x="560" y="135" width="220" height="70" rx="8" fill="#064E3B" stroke="#10B981" stroke-width="1.5"/>
<text x="670" y="165" fill="#A7F3D0" font-size="13" font-weight="700" text-anchor="middle">n8n Ingress Webhook</text>
<text x="670" y="185" fill="#34D399" font-size="11" text-anchor="middle">Validated Payload Ingestion</text>

1. Edge Layer: Cloudflare Worker Ingress Filter

This Cloudflare Worker inspects the incoming request, parses the client identification token (from a header or query parameter), queries an atomic Redis REST endpoint (such as Upstash or a self-hosted Redis via HTTP proxy), and rejects unauthorized or throttled traffic at the DNS edge.

// Cloudflare Worker: Multi-Tenant Ingress Router
export default {
  async fetch(request: Request, env: any): Promise<Response> {
    const tenantId = request.headers.get("X-Tenant-Key");

    if (!tenantId) {
      return new Response(JSON.stringify({ error: "MISSING_TENANT_IDENTIFIER" }), {
        status: 401,
        headers: { "Content-Type": "application/json" }
      });
    }

    const currentMinute = Math.floor(Date.now() / 60000);
    const redisKey = `ratelimit:${tenantId}:${currentMinute}`;

    // Increment tenant counter in Redis via REST API
    const redisRes = await fetch(`${env.UPSTASH_REDIS_REST_URL}/incr/${redisKey}`, {
      headers: { Authorization: `Bearer ${env.UPSTASH_REDIS_REST_TOKEN}` }
    });
    const { result: requestCount } = await redisRes.json();

    // Set TTL on the sliding window key
    if (requestCount === 1) {
      await fetch(`${env.UPSTASH_REDIS_REST_URL}/expire/${redisKey}/60`, {
        headers: { Authorization: `Bearer ${env.UPSTASH_REDIS_REST_TOKEN}` }
      });
    }

    // Enforce 120 requests/minute tier ceiling per tenant
    const TENANT_LIMIT = 120;
    if (requestCount > TENANT_LIMIT) {
      return new Response(
        JSON.stringify({ 
          error: "TENANT_RATE_LIMIT_EXCEEDED", 
          retry_after_seconds: 60 - (Math.floor(Date.now() / 1000) % 60) 
        }), 
        {
          status: 429,
          headers: { 
            "Content-Type": "application/json",
            "Retry-After": "60"
          }
        }
      );
    }

    // Forward sanitized request to internal n8n cluster tunnel
    const targetUrl = new URL(request.url);
    targetUrl.hostname = env.N8N_INTERNAL_HOST;

    const proxyRequest = new Request(targetUrl.toString(), request);
    proxyRequest.headers.set("X-Forwarded-Tenant", tenantId);

    return fetch(proxyRequest);
  }
};

2. In-Workflow Dynamic Routing

Once validated at the edge, the request enters n8n carrying the sanitized X-Forwarded-Tenant header. Instead of hardcoding static conditional trees, use a dynamic switch based on tenant routing metadata stored in Redis or PostgreSQL.

// n8n Code Node: Resolve Tenant Route
const tenantId = $input.item.json.headers['x-forwarded-tenant'];
const payload = $input.item.json.body;

// Mapping execution priority and routing target
const tenantTiers = {
  'tenant_enterprise_01': { priority: 'high', queue: 'priority-worker' },
  'tenant_standard_02': { priority: 'standard', queue: 'default-worker' }
};

const tenantConfig = tenantTiers[tenantId] || { priority: 'standard', queue: 'default-worker' };

return [{
  json: {
    tenant: tenantId,
    priority: tenantConfig.priority,
    destination_queue: tenantConfig.queue,
    data: payload,
    received_at: new Date().toISOString()
  }
}];

Production Blueprint Canvas (JSON)

This workflow receives the edge-filtered webhook, confirms authentication, and routes tasks to the appropriate pipeline branch:

{
  "name": "Multi-Tenant-Webhook-Ingress-Router",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "tenant-ingress",
        "options": {}
      },
      "id": "1",
      "name": "Edge Webhook Ingress",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [240, 300]
    },
    {
      "parameters": {
        "jsCode": "const tenant = $json.headers['x-forwarded-tenant'];\nif (!tenant) {\n  throw new Error('UNAUTHORIZED_EDGE_BYPASS: Missing required tenant header.');\n}\nreturn [{ json: { tenant_id: tenant, body: $json.body } }];"
      },
      "id": "2",
      "name": "Validate Edge Token",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [460, 300]
    },
    {
      "parameters": {
        "dataType": "string",
        "value1": "={{ $json.tenant_id }}",
        "rules": {
          "rules": [
            { "value2": "tenant_enterprise_01", "output": 0 },
            { "value2": "tenant_standard_02", "output": 1 }
          ]
        },
        "fallbackOutput": 2
      },
      "id": "3",
      "name": "Route by Tenant Tier",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3,
      "position": [680, 300]
    }
  ],
  "connections": {
    "Edge Webhook Ingress": {
      "main": [
        [
          { "node": "Validate Edge Token", "type": "main", "index": 0 }
        ]
      ]
    },
    "Validate Edge Token": {
      "main": [
        [
          { "node": "Route by Tenant Tier", "type": "main", "index": 0 }
        ]
      ]
    }
  }
}

Frequently Asked Questions

Why not implement rate limiting directly inside an n8n Code node?

Evaluating rate limits inside n8n means every dropped request still consumes an HTTP connection, triggers an execution record in PostgreSQL, and consumes Node.js event-loop CPU cycles. Under a denial-of-service or bug loop, your cluster will crash regardless. Blocking at the edge prevents abusive requests from ever touching your servers.

How do I handle varying rate limits across different subscription tiers?

Store tier thresholds in a Redis hash (HGET tenant:limits <tenant_id>). The Cloudflare Worker reads the specific limit per tenant during the request evaluation, allowing instant tier upgrades without redeploying code.


Publicaciones Similares