Kong and API Gateway Rate Limit Plugins

A gateway plugin is the first enforcement tier that knows who the caller is, which makes it the cheapest place to express “free tier gets 10 per second, pro gets 100” without touching application code. The catch is a storage policy that quietly decides whether your limit is exact or merely approximate, and a precedence model that decides which of four overlapping plugin configurations actually applies. Both belong to the tier discussion in edge and gateway enforcement; this page covers the configuration mechanics and the failure cases they produce.

The problem in concrete numbers

You attach a rate limiting plugin globally at 1,000 per minute, another to the /v1/reports route at 60 per minute, and a third to your enterprise consumer at 10,000 per minute. A request from that consumer to /v1/reports is subject to exactly one of those numbers — the most specific one that matches — not to all three. Teams routinely expect the intersection and get the override, then discover during an incident that the global ceiling they believed in was never applied to their largest customer.

Storage adds the second surprise. With the local policy each gateway node counts independently, so three nodes serving one consumer enforce 3× the configured limit — the same multiplication that affects proxy-level limiters. Switching to a shared Redis makes the number exact at the price of a round trip per request.

Plugin precedence from most to least specific A configuration attached to a consumer and route pair wins over one attached to a consumer, which wins over route, which wins over service, which wins over the global configuration; only the most specific match applies. Only the most specific configuration applies consumer + route (most specific — wins) consumer route service global (ignored when anything above matches)

Decision matrix: storage policy

Policy Accuracy Latency added Survives restart Right for
local Per node; total = limit × nodes ~0 No Coarse shielding on a single node, dev
cluster (database counters) Exact, but writes to the config database 1 DB round trip; heavy at scale Yes Small deployments already sized for DB writes
redis Exact fleet-wide 0.5–2 ms Yes, with persistence Production, any published limit
redis + sync_rate batching Near-exact, small overshoot Amortised Yes Very high request rates where per-request writes are too costly

The cluster policy is the one to avoid at scale: it turns every request into a write against the database that also holds your gateway configuration, which is the last component you want under load. Use Redis, dedicated to limiting, with an eviction policy that cannot delete live counters.

Step-by-step configuration

yaml
# kong.yaml (declarative config) — per-consumer limits with shared Redis counters.
_format_version: "3.0"

consumers:
  - username: acct_free_demo
    keyauth_credentials: [{ key: "demo_key_123" }]
  - username: acct_enterprise_7
    keyauth_credentials: [{ key: "ent_key_789" }]

services:
  - name: api
    url: http://origin.internal:8080
    routes:
      - name: v1
        paths: ["/v1"]

plugins:
  # 1. Identify the caller first — without this every request is anonymous.
  - name: key-auth
    service: api
    config: { key_names: ["X-API-Key"], hide_credentials: true }

  # 2. Default plan: applies to any consumer without an override.
  - name: rate-limiting
    service: api
    config:
      second: 10
      minute: 300              # two windows: burst control plus sustained budget
      policy: redis
      redis:
        host: redis-ratelimit.internal
        port: 6379
        database: 3            # dedicated DB, never shared with a cache
        timeout: 200           # ms — fail fast rather than adding latency
      limit_by: consumer       # not ip: an authenticated identity is the unit
      fault_tolerant: true     # allow traffic if Redis is unreachable (read paths)
      hide_client_headers: false
      error_code: 429
      error_message: "API rate limit exceeded"

  # 3. Enterprise override: attached to the consumer, so it wins over the service default.
  - name: rate-limiting
    consumer: acct_enterprise_7
    config:
      second: 200
      minute: 10000
      policy: redis
      redis: { host: redis-ratelimit.internal, port: 6379, database: 3 }
      limit_by: consumer
      fault_tolerant: false    # this customer's writes are expensive: fail closed

The two-window configuration (second plus minute) is the pattern to copy. A single per-minute limit lets a client spend the entire budget in the first second and stall for 59; a single per-second limit gives no useful monthly shape. Together they bound both burst and sustained use, and the gateway reports the tighter of the two in its headers.

Why two windows are better than one With only a per-minute limit a client spends the whole budget in the first second and waits; with only a per-second limit there is no sustained ceiling; with both, traffic is bounded in burst and in total. 300 per minute, three configurations minute only 300 in the first second then 59 s of nothing origin sees a spike worst for capacity planning second only smooth arrival shape no sustained ceiling 600 per minute possible budget is meaningless second + minute burst bounded at 10/s total bounded at 300 tighter window reported the configuration to copy the pair costs one extra counter per identity and removes both failure shapes

Gotchas and edge cases

  • limit_by: ip behind a load balancer counts the balancer. Set the gateway’s trusted-proxy configuration so the real client address is recovered, or key by consumer instead.
  • Anonymous traffic has no consumer. Requests that fail authentication never reach the consumer-scoped plugin, so an unauthenticated flood is limited only by whatever global or IP-based policy exists. Add one explicitly.
  • Plugin ordering matters. Authentication must run before the rate limiter, or limit_by: consumer silently degrades to a single shared bucket for everyone.
  • fault_tolerant: true is a silent unlimited mode. When Redis is unreachable the gateway admits everything. Alert on the plugin’s Redis error counter, not just on 429 rate.
  • Header stripping downstream. Some deployments strip unknown headers at an outer proxy, removing exactly the quota headers clients need. Assert their presence at the public edge, not at the gateway.
  • Changing a consumer’s plan does not reset their counter. Upgrading mid-window leaves the old window’s count in place; the new limit applies from the next window. Communicate that, or reset the key explicitly on upgrade.
  • One Redis for cache and counters. An eviction policy tuned for caching (allkeys-lru) will happily delete live rate limit keys under memory pressure. Use a dedicated database and volatile-ttl.
Plugin execution order decides whether per-consumer limits work When authentication runs before the limiter, each consumer gets its own bucket; when the limiter runs first there is no identity yet, so every caller shares a single anonymous bucket. Execution order is not cosmetic key-auth rate-limiting one bucket per consumer — correct rate-limiting key-auth one shared bucket for everyone — broken symptom: one noisy customer throttles every other customer at once

Verification and testing

bash
# 1. Precedence: does the enterprise override actually apply?
curl -is -H "X-API-Key: ent_key_789" https://api.example.com/v1/ping | \
  grep -i "ratelimit-limit"
# expect the override's numbers (200/s, 10000/min), not the service default

# 2. Fleet exactness: hammer one key and count accepted responses.
hey -z 20s -c 30 -H "X-API-Key: demo_key_123" https://api.example.com/v1/ping | \
  grep -E "\[200\]|\[429\]"
# expect ~200 accepted over 20 s (10/s) regardless of how many gateway nodes run

# 3. Failure policy: block Redis from one node and observe the configured behaviour.
sudo iptables -A OUTPUT -p tcp --dport 6379 -j DROP
curl -s -o /dev/null -w "%{http_code}\n" -H "X-API-Key: demo_key_123" https://api.example.com/v1/ping
# fault_tolerant true  -> 200 (and the Redis error counter must climb)
# fault_tolerant false -> 500 or 429 depending on version; either way, deliberate
sudo iptables -D OUTPUT -p tcp --dport 6379 -j DROP

Test one is worth running in CI against staging every time plugin configuration changes, because precedence mistakes are invisible until a large customer is affected. Wire it into the CI verification job alongside your header assertions.

Frequently Asked Questions

Should limits live in the gateway or in the application?

Put per-plan request limits in the gateway when plans are already modelled there — it keeps enforcement out of application code and applies uniformly across services. Keep anything that depends on request cost, account balance, or billing state in the application, because a gateway cannot see it.

Why do my per-consumer limits apply to everyone at once?

Almost always plugin ordering: if the rate limiter runs before authentication, there is no consumer yet, so every request falls into the same bucket. Ensure the authentication plugin has a lower execution order, and check that credentials are actually being matched rather than silently ignored.

Is the database-backed cluster policy safe in production?

Only at low request rates. It writes a counter update per request into the same database that stores gateway configuration, which becomes a bottleneck and a shared failure domain exactly when traffic is heaviest. Redis with a dedicated database is the production choice.

How do I give one customer a higher limit without changing the default?

Attach a second plugin instance to that consumer. Because consumer-scoped configuration is more specific than service or global configuration, it wins for that caller only, and the default continues to apply to everyone else. Avoid raising the global number as a shortcut — it silently widens the limit for every client.