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 🙂 |
