Securing Inbound Webhooks Using Token Bucket Rate Limiting

Securing Inbound Webhooks Using Token Bucket Rate Limiting

What You’ll Need

Table of Contents

Understanding the Token Bucket Algorithm for Webhooks

Inbound webhooks are standard entry points for modern backend automation systems. Whether you are consuming payment notifications from Stripe, CRM updates from HubSpot, or repository events from GitHub, inbound HTTP endpoints expose your infrastructure to unpredictable traffic spikes. Without strict access controls and execution thresholds, a sudden flood of webhook requests can easily overwhelm your database, saturate server memory, or spike execution costs across your automation toolchain.

Many developers default to simple fixed-window or sliding-window rate limiting. However, fixed-window limiters suffer from burst traffic vulnerabilities near window boundaries, while sliding log algorithms consume excessive memory under heavy load. The Token Bucket algorithm offers the ideal balance for inbound webhook protection.

The concept behind the token bucket is straightforward:

  1. Each incoming client key (such as an API tenant ID or IP address) is assigned a “bucket” with a maximum token capacity.
  2. The bucket fills with tokens at a constant rate (for instance, 5 tokens per second).
  3. When a webhook payload arrives, the system attempts to draw a required number of tokens from the bucket (usually 1 token per HTTP POST request).
  4. If sufficient tokens are available, the transaction proceeds, and the tokens are deducted.
  5. If the bucket is empty, the server rejects the request immediately with an HTTP 429 Too Many Requests status code.

This approach permits legitimate, short-lived traffic bursts (up to the max bucket capacity) while guaranteeing that long-term continuous traffic never exceeds the predefined refill rate. Controlling execution throughput at your API entry point prevents expensive downstream operations. If you want to evaluate how resource consumption translates to running costs across different backend architectures, read my analysis on Windmill vs n8n vs Make workflow pricing 2026.

Building a Redis-Backed Token Bucket Engine in Node.js

To make our rate-limiting layer scale across multiple backend server instances, we store bucket states inside a centralized Redis cache. Doing these calculations directly in Node.js application memory would create consistency issues when running behind a load balancer.

To prevent race conditions when multiple webhooks arrive simultaneously, we execute our token bucket evaluation using atomic Redis Lua scripts. The script checks the timestamp of the last request, calculates the newly accumulated tokens based on elapsed time, deducts the requested token amount, and updates the bucket state in a single atomic transaction.

Deploy your Redis service and Node.js middleware on a reliable host like a Hetzner VPS or DigitalOcean droplet to ensure ultra-low network latency between your API gateway and your database cache.

Below is the complete, production-ready Node.js Express server using the ioredis client and raw cryptographic signature validation for secured inbound webhooks.

import express from 'express';
import Redis from 'ioredis';
import crypto from 'crypto';

const app = express();
const redis = new Redis({
  host: process.env.REDIS_HOST || '127.0.0.1',
  port: Number(process.env.REDIS_PORT) || 6379,
  password: process.env.REDIS_PASSWORD || undefined,
});

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || 'super-secret-signing-key-12345';

app.use(express.json({
  verify: (req, res, buf) => {
    req.rawBody = buf;
  }
}));

const luaTokenBucketScript = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local fill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

local data = redis.call("HMGET", key, "tokens", "last_updated")
local tokens = tonumber(data[1])
local last_updated = tonumber(data[2])

if tokens == nil then
  tokens = capacity
  last_updated = now
else
  local delta = math.max(0, now - last_updated)
  local tokens_to_add = delta * fill_rate
  tokens = math.min(capacity, tokens + tokens_to_add)
end

if tokens >= requested then
  tokens = tokens - requested
  redis.call("HMSET", key, "tokens", tokens, "last_updated", now)
  redis.call("EXPIRE", key, math.ceil(capacity / fill_rate) * 2)
  return {1, math.floor(tokens), 0}
else
  local missing = requested - tokens
  local retry_after = math.ceil(missing / fill_rate)
  return {0, math.floor(tokens), retry_after}
end
`;

redis.defineCommand('consumeToken', {
  numberOfKeys: 1,
  lua: luaTokenBucketScript,
});

function verifyWebhookSignature(req) {
  const signature = req.headers['x-webhook-signature'];
  if (!signature) {
    return false;
  }
  const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET);
  const digest = hmac.update(req.rawBody).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest));
}

async function rateLimiterMiddleware(req, res, next) {
  const clientId = req.headers['x-client-id'] || req.ip;
  const rateLimitKey = `rate_limit:webhook:${clientId}`;
  
  const capacity = 10;
  const fillRate = 2;
  const now = Math.floor(Date.now() / 1000);
  const requestedTokens = 1;

  try {
    const result = await redis.consumeToken(
      rateLimitKey,
      capacity,
      fillRate,
      now,
      requestedTokens
    );

    const allowed = result[0] === 1;
    const remainingTokens = result[1];
    const retryAfter = result[2];

    res.setHeader('X-RateLimit-Limit', capacity);
    res.setHeader('X-RateLimit-Remaining', remainingTokens);

    if (!allowed) {
      res.setHeader('Retry-After', retryAfter);
      return res.status(429).json({
        error: 'Too Many Requests',
        message: 'Rate limit exceeded. Bucket exhausted.',
        retryAfterSeconds: retryAfter,
      });
    }

    next();
  } catch (error) {
    console.error('Redis Rate Limiter Error:', error);
    return res.status(500).json({ error: 'Internal Server Error' });
  }
}

app.post('/api/v1/webhooks/incoming', rateLimiterMiddleware, (req, res) => {
  if (!verifyWebhookSignature(req)) {
    return res.status(401).json({ error: 'Unauthorized', message: 'Invalid signature payload.' });
  }

  const payload = req.body;
  console.log('Processing valid webhook payload:', payload.eventId || 'generic-event');

  return res.status(200).json({
    status: 'success',
    message: 'Webhook received and queued for processing.',
    timestamp: new Date().toISOString(),
  });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Webhook rate-limiting proxy running on port ${PORT}`);
});

💡 Fast-Track Your Project: Don’t want to configure this yourself? I build custom n8n pipelines and bots. Message me with code SYS3-HUGO.

Integrating Rate Limiting with Automated Orchestration Workflows

Once incoming webhooks clear your protective middleware proxy, they can be safely passed to your downstream processing pipelines. Orchestration platforms like n8n Cloud or self-hosted n8n instances excel at parsing data, executing branch logic, and distributing payloads to databases and internal services.

When handling heavy automation workflows, such as video rendering or large batch transformations, unfiltered webhook spikes can overload your workers. For example, if you deploy background media processing services similar to those described in my guide on How to Build a YouTube Upload Bot with Node.js and OAuth2, sending hundreds of simultaneous trigger events will quickly hit API rate limits on target third-party services.

By placing our custom token bucket reverse proxy in front of your orchestration tool, you ensure that incoming jobs enter your execution queue at an controlled rate.

+------------------+      HTTP POST       +-------------------------+
| External Webhook | -------------------> | Node.js Express Proxy   |
| Sender (Stripe)  |                      | (Token Bucket + Redis)  |
+------------------+                      +-------------------------+
                                                       |
                                            Passes Rate Limit Check
                                                       v
+------------------+      HTTP POST       +-------------------------+
| Automation Flow  | <------------------- | n8n Webhook Node        |
| (Database / API) |                      | (Orchestrator Trigger)  |
+------------------+                      +-------------------------+

For persistent state management and auditing, you can log incoming, rate-limited webhook entries directly into a light, decoupled database engine. Learn how to set up an efficient data store in my step-by-step tutorial on Deploying Self-Hosted PocketBase on Cloud Servers.

Load Testing and Stress Testing Your Rate Limiter

To prove that our Redis-backed token bucket algorithm performs as designed under sudden load spikes, we can write a dedicated load-testing script using Node.js. This script simulates a rapid burst of 20 HTTP POST requests originating from the same client identifier, while automatically generating a valid HMAC signature for each request.

Save the following executable code as test-load.js and execute it against your local or hosted API gateway.

import crypto from 'crypto';

const TARGET_URL = 'http://127.0.0.1:3000/api/v1/webhooks/incoming';
const WEBHOOK_SECRET = 'super-secret-signing-key-12345';
const CLIENT_ID = 'partner_service_alpha';

function generateSignature(payloadString) {
  return crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(payloadString)
    .digest('hex');
}

async function sendWebhookRequest(index) {
  const payload = JSON.stringify({
    eventId: `evt_test_${index}_${Date.now()}`,
    action: 'user.created',
    data: { userId: 1000 + index }
  });

  const signature = generateSignature(payload);

  try {
    const response = await fetch(TARGET_URL, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Client-ID': CLIENT_ID,
        'X-Webhook-Signature': signature,
      },
      body: payload,
    });

    const responseData = await response.json();
    const rateLimitRemaining = response.headers.get('x-ratelimit-remaining');
    const retryAfter = response.headers.get('retry-after');

    console.log(`[Request #${index.toString().padStart(2, '0')}] Status: ${response.status} | Remaining Tokens: ${rateLimitRemaining || 'N/A'} | Retry-After: ${retryAfter || 'None'} | Message: ${responseData.message || responseData.error}`);
  } catch (error) {
    console.error(`[Request #${index}] Network Failure:`, error.message);
  }
}

async function runBenchmark() {
  console.log('Starting Webhook Token Bucket Rate Limiter Benchmark...');
  console.log('Firing burst of 20 simultaneous requests...\n');

  const requestPromises = [];
  for (let i = 1; i <= 20; i++) {
    requestPromises.push(sendWebhookRequest(i));
  }

  await Promise.all(requestPromises);

  console.log('\nBurst complete. Waiting 3 seconds to allow token bucket refill...');
  await new Promise((resolve) => setTimeout(resolve, 3000));

  console.log('\nSending follow-up request to test bucket token recovery...');
  await sendWebhookRequest(21);
}

runBenchmark();

When you execute this benchmark, the output clearly shows the initial 10 requests passing through successfully while consuming available bucket capacity. Once the capacity drops to zero, subsequent requests are immediately blocked with HTTP status 429 Too Many Requests. After waiting three seconds, the token bucket refills, and subsequent calls are accepted once again.

Getting Started

To implement this token bucket architecture in your own production infrastructure:

  1. Provision a VPS instance using Hetzner VPS or Contabo VPS.
  2. Install Node.js, Express, and a Redis server instance.
  3. Configure your API routing layer using a domain registered through Namecheap.
  4. Point your reverse proxy to your workflow targets hosted on n8n Cloud.

Outsource Your Automation

Don’t have time? I build production n8n workflows, WhatsApp bots, and fully automated YouTube Shorts pipelines. Hire me on Fiverr, mention SYS3-HUGO for priority. Or DM at chasebot.online.

Want to automate this yourself?

Start with n8n Cloud (free tier available) or self-host on a Hetzner VPS for full control.

Want this engine running on your own VPS?

This blog publishes itself — daily, unattended, on free API tiers. The full engine, Hugo theme, and setup guide are available as System 3.

Get System 3
system online