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
- WordPress REST API Handbook
- WordPress Plugin Developer Handbook
- Redis documentation
- nginx (proxy rate-limiting features)
|
Acepto donaciones de BAT's mediante el navegador Brave 🙂 |