APISIX rate limiting and JWT claim routing

Published: 2026-03-03

Apache APISIX's real power over nginx-based ingresses is the plugin system. Where nginx requires Lua modules and complex config rewrites, APISIX routes, rate-limits, and transforms requests with a few lines of YAML via Kubernetes CRDs. Here's how we use it in practice.


ApisixRoute: the basic unit

yamlapiVersion: apisix.apache.org/v2
kind: ApisixRoute
metadata:
  name: api-route
  namespace: app
spec:
  http:
    - name: main
      match:
        hosts:
          - api.example.com
        paths:
          - /*
      backends:
        - serviceName: my-api-service
          servicePort: 80
      plugins:
        - name: redirect
          enable: true
          config:
            http_to_https: true

Routes are per-spec.http entry. Multiple entries on one ApisixRoute, or multiple ApisixRoute objects per host — both work.


Rate limiting per agency from JWT claim

One pattern we use heavily: extract a claim from the JWT and use it as the rate limit key, so different clients get different limits.

yamlplugins:
  # 1. Global rate limit (all clients combined)
  - name: limit-count
    enable: true
    config:
      count: 17
      time_window: 1
      rejected_code: 429
      key_type: constant

  # 2. Per-client limit via JWT agency-id claim
  - name: serverless-pre-function
    enable: true
    config:
      phase: rewrite
      functions:
        - |-
          return function(conf, ctx)
            local core = require("apisix.core")
            local jwt  = require("resty.jwt")
            local token = core.request.header(ctx, "Authorization")
            if token then
              local _, _, t = string.find(token, "Bearer%s+(.+)")
              if t then
                local obj = jwt:load_jwt(t)
                if obj.valid then
                  local agency = obj.payload["agency-id"]
                  core.request.set_header(ctx, "X-Agency-Id", agency)
                end
              end
            end
          end

The Lua serverless function runs in the rewrite phase — before the request is proxied. It reads the JWT, extracts agency-id, and sets a header that downstream plugins can use for routing decisions.

Rate limit by agency

yaml  # 3. Limit-count per agency (using the extracted header)
  - name: limit-count
    enable: true
    config:
      count: 100
      time_window: 60
      rejected_code: 429
      key_type: var
      key: http_x_agency_id
      group: agency-rate-limit

Each unique X-Agency-Id value gets its own counter. VIP agencies can be excluded by checking the header value in the Lua function first.


Per-path routing with regex

Route different API paths to different backends:

yamlspec:
  http:
    - name: airshopping
      match:
        hosts: [api.example.com]
        paths: [/*]
        exprs:
          - subject:
              scope: Path
            op: RegexMatchCaseInsensitive
            value: "^/api/order/(airshopping|offerprice)$"
      backends:
        - serviceName: search-service
          servicePort: 80

    - name: booking
      match:
        hosts: [api.example.com]
        paths: [/api/order/createorder]
      backends:
        - serviceName: booking-service
          servicePort: 80

    - name: fallback
      match:
        hosts: [api.example.com]
        paths: [/*]
        priority: -1
      backends:
        - serviceName: default-service
          servicePort: 80

exprs provide predicate matching on path, headers, or query params. RegexMatchCaseInsensitive is the most flexible option. Use priority to control which rule wins when multiple paths match.


ApisixUpstream: health checks and load balancing

yamlapiVersion: apisix.apache.org/v2
kind: ApisixUpstream
metadata:
  name: my-api-upstream
  namespace: app
spec:
  loadbalancer:
    type: roundrobin
  healthCheck:
    active:
      type: http
      httpPath: /healthz
      interval: 10
      timeout: 2
      successThreshold: 1
      failureThreshold: 3
      httpStatusCode: [200]
  externalNodes:
    - type: Service
      name: my-api-service
      port: 80
      weight: 100

Active health checks probe /healthz every 10 seconds. After 3 consecutive failures, the upstream is marked unhealthy and removed from rotation. APISIX handles this in the data plane without involving Kubernetes.


Traffic splitting for canary releases

yamlspec:
  http:
    - name: canary
      match:
        hosts: [api.example.com]
        paths: [/*]
      backends:
        - serviceName: api-stable
          servicePort: 80
          weight: 90
        - serviceName: api-canary
          servicePort: 80
          weight: 10

10% of traffic goes to the canary version. No additional tooling — just a weight ratio in the route config. Adjust weights via GitOps or a kubectl patch.


Key-auth plugin for API key access

yamlplugins:
  - name: key-auth
    enable: true
    config:
      header: "X-API-Key"
      query: "apikey"

Then create an ApisixConsumer with the allowed key:

yamlapiVersion: apisix.apache.org/v2
kind: ApisixConsumer
metadata:
  name: my-client
  namespace: app
spec:
  authParameter:
    keyAuth:
      value:
        key: "my-secret-api-key"

Requests without a valid X-API-Key header return 401 at the gateway — the upstream service never sees unauthenticated traffic.


Request transformation

Strip headers before forwarding to upstream, or add custom ones:

yamlplugins:
  - name: proxy-rewrite
    enable: true
    config:
      headers:
        remove:
          - Authorization   # don't forward auth token to upstream
          - X-Agency-Id
        add:
          X-Forwarded-Service: "api-gateway"
      regex_uri:
        - "^/api/v1/(.*)"
        - "/internal/$1"    # rewrite path

Debugging routes

bash# List all ApisixRoutes
kubectl get apisixroute -n app

# Check route details
kubectl describe apisixroute api-route -n app

# Check APISIX data plane routes via admin API
kubectl port-forward -n ingress-apisix svc/apisix-admin 9080:9080
curl -s http://localhost:9080/apisix/admin/routes | jq '[.list[] | {name: .value.name, uri: .value.uri}]'