How to apply rate limiting to REST endpoints in PHP in WordPress

Contents

Introduction

Rate limiting protects WordPress REST endpoints from abuse, brute-force, accidental floods, and resource exhaustion. Applying rate limits to REST routes helps preserve CPU, memory, and third-party API quotas, and improves overall site reliability.

This article covers concepts, algorithms, WordPress-specific hooks, storage options, headers, testing, and multiple production-ready PHP examples (fixed window, atomic cache-based, token-bucket). Each code example is placed in the required

 tag so it can be embedded directly.

Key concepts and goals

  • Who to limit: anonymous clients (by IP), authenticated users (by user ID), API keys, or global/service-level throttling.
  • What to limit: per-route, per-method (GET/POST), or aggregated across several endpoints.
  • Windowing: fixed window, sliding window, token bucket/leaky bucket. Each has tradeoffs around fairness, burst capacity, and implementation complexity.
  • Storage and atomicity: counters must be stored where reads/writes are fast and ideally atomic (object cache such as Redis/Memcached). Transients/options are simple but can cause race conditions in high concurrency.
  • Response handling: return HTTP 429 with Retry-After and informative X-RateLimit- headers.

Rate-limiting algorithms (overview)

  • Fixed window: Count requests in a window (e.g., 60 seconds). Simple, but vulnerable to spikes at window edges.
  • Sliding window: Better fairness by tracking per-request timestamps and computing counts for last N seconds. More complex and heavier on storage.
  • Token bucket (recommended for most APIs): Tokens accumulate at a fixed rate requests consume tokens. Allows controlled bursts and steady average rate.
  • Leaky bucket: Similar to token bucket but focuses on smoothing out bursts can be implemented with timestamps and queueing semantics.

Choosing storage for counters

  • Object cache (preferred): wp_cache_ functions or underlying Redis/Memcached. Supports atomic increment (wp_cache_incr) and high throughput.
  • Transients: Easy to use (set_transient/get_transient) but not atomic and may be stored in options table if object cache absent.
  • Options/user_meta: Persistent but heavy and not recommended for high-frequency updates.
  • Custom DB table: Useful for advanced analytics or complex sliding-window logic, but adds complexity and queries.
  • External services: Use Rate-Limiting features on a proxy (Cloudflare, nginx limit_req) or a dedicated service when scale demands it.

Client identification

Choose a stable identifier per client:

  • Authenticated users: use user ID
  • Anonymous clients: use IP address (careful with proxies/load balancers)
  • API keys or tokens: use the key value

To get the IP reliably, consider examining headers introduced by trusted reverse proxies (X-Forwarded-For, X-Real-IP) and falling back to _SERVER[REMOTE_ADDR]. Sanitize and validate IPv4/IPv6. Never trust X-Forwarded-For if it originates from the public internet without a trusted proxy.

Where to plug rate limiting into the WP REST pipeline

  • Per-route permission_callback: register_rest_route accepts a permission_callback which can block a request before the endpoint handler runs. Good for per-route rules.
  • Global middleware: add_filter(rest_pre_dispatch, ...). Returning a WP_Error or WP_REST_Response from the filter aborts normal dispatch and returns immediately. Good for centralized policy enforcement across all routes.
  • After-dispatch headers: add_filter(rest_post_dispatch, ...) to add headers such as X-RateLimit-Remaining when you need to attach info to successful responses.

Headers to send

  • X-RateLimit-Limit: total allowed per window
  • X-RateLimit-Remaining: remaining requests in the window
  • X-RateLimit-Reset: epoch timestamp when the window resets
  • Retry-After: seconds the client should wait when returning 429

Example 1 — Simple fixed-window rate limit using transients (easy, but not atomic)

This example demonstrates a per-IP, per-route fixed-window limit (e.g., 60 requests per minute). It is simple but may suffer race conditions under high concurrency. Suitable for low-traffic sites.

lt?php
// Simple fixed-window rate limiting in a REST permission_callback or global hook.

function simple_rest_rate_limit_check( request ) {
    // configuration
    limit = 60 // requests
    window = 60 // seconds

    // identify client: prefer authenticated user
    user_id = get_current_user_id()
    if ( user_id ) {
        identifier = user: . user_id
    } else {
        // get IP - trust X-Forwarded-For only if your site sits behind a trusted proxy
        ip = 
        if ( ! empty( _SERVER[HTTP_X_FORWARDED_FOR] ) ) {
            ip = explode( ,, _SERVER[HTTP_X_FORWARDED_FOR] )[0]
        } else {
            ip = _SERVER[REMOTE_ADDR] ?? 
        }
        identifier = ip: . ip
    }

    // route identification
    route = request->get_route() ?: unknown_route
    method = request->get_method()

    key = rl: . md5( route .  . method .  . identifier )

    count = get_transient( key )
    if ( false === count ) {
        // first call in window
        set_transient( key, 1, window )
        count = 1
        remaining = limit - count
        reset = time()   window
    } else {
        count  
        // update transient expiration to remaining seconds
        // NOTE: set_transient will update expiration and value
        set_transient( key, count, window )
        remaining = max( 0, limit - count )
        // best-effort compute reset: transient TTL isnt directly queryable, so use window from now
        reset = time()   window
    }

    if ( count > limit ) {
        retry_after = window // simplistic
        error = new WP_Error(
            rest_rate_limit,
            Rate limit exceeded,
            array( status => 429 )
        )
        response = rest_ensure_response( error )
        response->header( Retry-After, retry_after )
        response->header( X-RateLimit-Limit, limit )
        response->header( X-RateLimit-Remaining, 0 )
        response->header( X-RateLimit-Reset, reset )
        return response // short-circuit the request
    }

    // allow the request
    response = null // returning null allows dispatch to continue
    // Optionally attach headers via rest_post_dispatch
    return response
}
// Usage: as permission_callback for a route, or hooked into rest_pre_dispatch
// add_filter(rest_pre_dispatch, simple_rest_rate_limit_check, 10, 1 )
?gt

Notes about Example 1

  • set_transient/get_transient are simple, but not atomic and may lead to multiple simultaneous increments exceeding the limit.
  • Transient expiration and TTL alignment are approximate because WordPress transients dont expose remaining TTL directly.

Example 2 — Atomic increment using the object cache (preferred when available)

Use wp_cache_add and wp_cache_incr to get atomic increments in caches that support it (Redis/Memcached). This provides better correctness under concurrency. The example below implements a fixed-window limit while storing the TTL in the added cache entry.

lt?php
function cache_atomic_rate_limit_check( request ) {
    limit  = 100 // allowed requests
    window = 60  // seconds

    user_id = get_current_user_id()
    if ( user_id ) {
        identifier = user: . user_id
    } else {
        ip = 
        if ( ! empty( _SERVER[HTTP_X_FORWARDED_FOR] ) ) {
            ip = explode( ,, _SERVER[HTTP_X_FORWARDED_FOR] )[0]
        } else {
            ip = _SERVER[REMOTE_ADDR] ?? 
        }
        identifier = ip: . ip
    }

    route  = request->get_route() ?: unknown
    method = request->get_method()
    key    = rl:{route}:{method}: . md5( identifier )

    // rate_limits is a custom cache group you can leave empty string if needed
    added = wp_cache_add( key, 1, rate_limits, window )
    if ( added ) {
        count = 1
    } else {
        // attempt atomic increment
        count = wp_cache_incr( key, 1, rate_limits )
        if ( false === count ) {
            // fallback: cache backend may not support incr emulate with get/set (not ideal)
            count = wp_cache_get( key, rate_limits )
            if ( false === count ) {
                wp_cache_set( key, 1, rate_limits, window )
                count = 1
            } else {
                count  
                wp_cache_set( key, count, rate_limits, window )
            }
        }
    }

    remaining = max( 0, limit - count )
    // When using wp_cache_, you usually cannot read exact TTL calculate reset conservatively.
    reset = time()   window

    if ( count > limit ) {
        retry_after = window
        error = new WP_Error(
            rest_rate_limit,
            Rate limit exceeded,
            array( status => 429 )
        )
        response = rest_ensure_response( error )
        response->header( Retry-After, retry_after )
        response->header( X-RateLimit-Limit, limit )
        response->header( X-RateLimit-Remaining, 0 )
        response->header( X-RateLimit-Reset, reset )
        return response
    }
    return null
}
// add_filter(rest_pre_dispatch, cache_atomic_rate_limit_check, 10, 1 )
?gt

Notes about Example 2

  • Atomic increments via wp_cache_incr are supported only by backends that implement it. Test on your environment.
  • Use a unique cache group or prefix to avoid collisions with other transient usage.
  • wp_cache_add allows setting TTL directly on many cache backends so the key auto-expires at window end.

Example 3 — Token bucket algorithm (allows bursts but enforces sustained rate)

The token bucket stores two values: current tokens and last refill timestamp. Tokens refill at a rate of tokens_per_second up to a maximum bucket capacity. When a request arrives, attempt to consume one token. This approach is fair and supports controlled bursts.

lt?php
function token_bucket_rate_limit_check( request ) {
    // configuration
    rate_per_minute = 60 // average allowed per minute
    window_seconds   = 60
    capacity         = 120 // max burst tokens (bucket size)

    tokens_per_second = rate_per_minute / 60.0

    // identify client
    user_id = get_current_user_id()
    if ( user_id ) {
        identifier = user: . user_id
    } else {
        if ( ! empty( _SERVER[HTTP_X_FORWARDED_FOR] ) ) {
            ip = explode( ,, _SERVER[HTTP_X_FORWARDED_FOR] )[0]
        } else {
            ip = _SERVER[REMOTE_ADDR] ?? 
        }
        identifier = ip: . ip
    }

    route  = request->get_route() ?: unknown
    method = request->get_method()
    key    = tb: . md5( route .  . method .  . identifier )

    // Use object cache for better concurrency fall back to option/transient if needed.
    state = wp_cache_get( key, rate_limits )
    if ( false === state ) {
        // initial state: full bucket
        tokens = capacity
        last   = microtime( true )
    } else {
        tokens = state[tokens]
        last   = state[last]
    }

    now = microtime( true )
    elapsed = max( 0.0, now - last )
    // refill tokens
    tokens = min( capacity, tokens   elapsed  tokens_per_second )
    last = now

    if ( tokens >= 1.0 ) {
        // consume a token
        tokens -= 1.0

        // store back state TTL not critical but helpful
        wp_cache_set( key, array( tokens => tokens, last => last ), rate_limits, window_seconds )

        // Compute remaining approximated as floor(tokens)
        remaining = floor( tokens )
    } else {
        // No tokens available -> rate limit
        // estimate when next token will be available
        seconds_to_next = ceil( (1.0 - tokens) / tokens_per_second )

        error = new WP_Error(
            rest_rate_limit,
            Rate limit exceeded,
            array( status => 429 )
        )
        response = rest_ensure_response( error )
        response->header( Retry-After, seconds_to_next )
        response->header( X-RateLimit-Limit, rate_per_minute )
        response->header( X-RateLimit-Remaining, 0 )
        response->header( X-RateLimit-Reset, time()   seconds_to_next )
        return response
    }

    return null
}
// add_filter(rest_pre_dispatch, token_bucket_rate_limit_check, 10, 1 )
?gt

Notes about Example 3

  • Token bucket works well for allowing short bursts while enforcing a sustained average rate.
  • Race conditions can still occur if cache lacks atomic read-modify-write. For strict correctness, use a cache backend with Lua scripts (Redis) or atomic update primitives.

How to attach rate-limit headers to all responses

When the check runs in rest_pre_dispatch it may return null and allow the request to proceed. To add headers to successful responses (X-RateLimit-), hook rest_post_dispatch to add the last known counters. Use a shared mechanism (e.g., transient or request-global) to communicate remaining counters between pre_check and post_dispatch.

lt?php
// Example: add headers globally assumes your pre-dispatch stored info in a request attribute or shared cache.
function add_rate_limit_headers( result, server, request ) {
    // Inspect cache or request attributes to compute limit/remaining/reset
    // This is illustrative actual implementation depends on how you store counters.
    limit = 60
    remaining = 42
    reset = time()   30

    if ( is_wp_error( result ) ) {
        return result
    }
    response = rest_ensure_response( result )
    response->header( X-RateLimit-Limit, limit )
    response->header( X-RateLimit-Remaining, remaining )
    response->header( X-RateLimit-Reset, reset )
    return response
}
// add_filter(rest_post_dispatch, add_rate_limit_headers, 10, 3)
?gt

Getting client IP correctly (tips)

  • If your site sits behind a reverse proxy or load balancer (Cloudflare, AWS ELB, nginx), configure WordPress to trust proxy headers only from those IPs. Do not unconditionally trust X-Forwarded-For from the public internet.
  • Example strategy: if HTTP_X_FORWARDED_FOR exists and REMOTE_ADDR equals a known reverse proxy IP, use the forwarded header otherwise use REMOTE_ADDR.
  • Sanitize the IP with filter_var(ip, FILTER_VALIDATE_IP) and consider normalizing IPv6 addresses.

Fallbacks and hardening

  • When object cache lacks atomic primitives, consider using a central Redis with atomic Lua scripts or put the rate-limiting at reverse-proxy level (nginx limit_req) for best reliability.
  • Exclude health-checks, internal webhooks, or admin users from limits by whitelisting known IPs or user roles.
  • Log denied attempts with context (route, identifier, timestamp) for auditing and false-positive analysis.
  • Expose metrics to monitoring (Prometheus, NewRelic) to observe rate-limit hits and tune thresholds.

Testing your rate limiter

Use curl or a script to simulate repeated requests and verify 429 responses and headers.

# Example: send 10 rapid requests to an endpoint
for i in {1..10} do
  curl -i -s https://example.com/wp-json/my-plugin/v1/endpoint
  echo -e nn-----n
done

Security and privacy considerations

  • When logging identifiers, avoid storing full IPs long-term if privacy is a concern. Use hashing or truncation.
  • Be careful with user enumeration: do not leak whether a user exists through different rate-limit behavior unless intentional.
  • Do not rely on client-supplied headers for critical decisions unless they come from a trusted proxy.

Operational recommendations

  • Prefer object cache (Redis/Memcached) for counters in production configure persistent connection and fast TTL operations.
  • Keep rate-limit logic as light as possible to avoid adding overhead to every REST request. Do cheap checks first (e.g., whitelist checks).
  • Use monitoring/alerts for increases in rate-limit hits — they may indicate bot activity or misconfiguration.
  • When possible, implement rate-limiting as early as possible (reverse proxy) to save PHP/WordPress cycles.

Troubleshooting common pitfalls

  • False positives with shared IPs: Multiple users behind NAT will share the same public IP. Use per-user limits for authenticated clients.
  • Race conditions: Rapid concurrent requests may produce slightly higher counts with non-atomic caches. Use atomic increments or server-side blocking (proxy) for strict requirements.
  • Header mismatch: Some clients may not accept custom headers still send Retry-After for compatibility.

Full example: compact plugin-style global limiter using rest_pre_dispatch

Below is an example pattern to use in a small plugin file. It uses wp_cache_incr where available and returns 429 with headers on limit exceed. Adjust configuration, whitelists, and storage to your environment.

lt?php
/
  Plugin: Simple REST Rate Limiter
  Note: For production, expand whitelisting, trusted-proxy handling, logging, and caching backend checks.
 /

add_filter( rest_pre_dispatch, simple_global_rest_rate_limiter, 10, 3 )
function simple_global_rest_rate_limiter( result, server, request ) {
    // Configuration
    limit = 120
    window = 60

    // Optional: skip admin, logged-in admins, or health checks
    if ( is_user_logged_in()  current_user_can( manage_options ) ) {
        return null
    }

    // Identify client
    user_id = get_current_user_id()
    if ( user_id ) {
        identifier = user: . user_id
    } else {
        ip = 
        // Replace this with logic that trusts X-Forwarded-For only from known proxies
        if ( ! empty( _SERVER[HTTP_X_FORWARDED_FOR] ) ) {
            ip = explode( ,, _SERVER[HTTP_X_FORWARDED_FOR] )[0]
        } else {
            ip = _SERVER[REMOTE_ADDR] ?? 
        }
        identifier = ip: . ip
    }

    route = request->get_route() ?: unknown
    method = request->get_method()
    key = rl: . md5( route .  . method .  . identifier )

    // Try to use atomic cache incr
    added = wp_cache_add( key, 1, rate_limits, window )
    if ( added ) {
        count = 1
    } else {
        count = wp_cache_incr( key, 1, rate_limits )
        if ( false === count ) {
            // fallback to transient (not atomic)
            count = get_transient( key )
            if ( false === count ) {
                set_transient( key, 1, window )
                count = 1
            } else {
                count  
                set_transient( key, count, window )
            }
        }
    }

    remaining = max( 0, limit - count )
    reset = time()   window

    if ( count > limit ) {
        retry_after = window
        error = new WP_Error( rest_rate_limit, Rate limit exceeded, array( status => 429 ) )
        response = rest_ensure_response( error )
        response->header( Retry-After, retry_after )
        response->header( X-RateLimit-Limit, limit )
        response->header( X-RateLimit-Remaining, 0 )
        response->header( X-RateLimit-Reset, reset )
        return response
    }

    // Optionally attach these to the response later using rest_post_dispatch. For simplicity, store in request attributes:
    request->set_param( _rl_limit, limit )
    request->set_param( _rl_remaining, remaining )
    request->set_param( _rl_reset, reset )

    return null
}

add_filter( rest_post_dispatch, attach_rate_limit_headers, 10, 3 )
function attach_rate_limit_headers( result, server, request ) {
    limit = request->get_param( _rl_limit )
    remaining = request->get_param( _rl_remaining )
    reset = request->get_param( _rl_reset )

    if ( empty( limit ) ) {
        // No rate-limit info, do nothing
        return result
    }

    response = rest_ensure_response( result )
    response->header( X-RateLimit-Limit, limit )
    response->header( X-RateLimit-Remaining, remaining )
    response->header( X-RateLimit-Reset, reset )
    return response
}
?gt

When to push rate-limiting outside WordPress

  • For high-scale systems, enforce limits at the edge (reverse proxy, CDN, or API gateway). This avoids consuming PHP/WordPress resources for each blocked request.
  • Edge-level rate-limiting typically offers more efficient and accurate enforcement (native counters, atomicity) and better protection during surges.

Summary and best practices

  • Decide per-route and per-client strategy: authenticated users vs IP-based limits.
  • Prefer object cache (Redis/Memcached) for counters and atomic increments use token-bucket for bursts with steady average limits.
  • Return HTTP 429 with Retry-After and X-RateLimit headers so clients can back off gracefully.
  • Centralize logic with rest_pre_dispatch for site-wide rules or permission_callback for per-route enforcement.
  • Monitor, log, and test thoroughly in staging before production rollout.

Useful links



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *