Your Magento 2 REST and GraphQL APIs are public-facing endpoints that power everything from mobile apps to third-party integrations. Left unprotected, they become easy targets for abuse: scrapers hammering your catalog endpoints, brute-force attacks against customer accounts, or competitors systematically draining your inventory data. Rate limiting is the defensive layer most stores skip — until it's too late.
This guide covers practical rate limiting strategies for Magento 2, from built-in mechanisms to custom middleware, with production-ready configuration examples.
Why Rate Limiting Matters for Magento 2
Magento 2 exposes extensive APIs by default:
-
REST API:
/rest/V1/...— products, orders, customers, inventory -
GraphQL:
/graphql— storefront queries, mutations - Admin REST: OAuth-protected backend endpoints
Without throttling, a single malicious client or misconfigured integration can:
- Exhaust PHP-FPM worker pools, causing 502/504 errors for real shoppers
- Trigger database lock contention during bulk catalog reads
- Hit Elasticsearch/OpenSearch with expensive aggregation queries
- Deplete Redis connection pools or memory with uncached repeated requests
- Reveal sensitive business data through systematic enumeration
The cost isn't just infrastructure — it's lost revenue when legitimate checkout traffic gets squeezed out.
Built-In Magento 2 API Throttling
REST API Rate Limits (WebAPI Framework)
Magento 2's WebAPI layer has configurable rate limiting via env.php:
// app/etc/env.php
'system' => [
'webapi' => [
'rest' => [
'max_requests' => 100,
'time_period' => 60 // seconds
],
'graphql' => [
'max_requests' => 200,
'time_period' => 60
]
]
]
Important caveat: This built-in rate limiter is per-IP by default and applies globally across all REST endpoints. It's a blunt instrument: a customer browsing via mobile app on a shared office IP hits the same ceiling as a scraper. Production stores need more nuanced control.
WebAPI Throttling per Integration
For OAuth-connected integrations, you can set custom limits:
// app/etc/env.php
'system' => [
'webapi' => [
'throttling' => [
'default' => [
'max_requests' => 500,
'time_period' => 60
],
'integrations' => [
'my_erp_integration' => [
'max_requests' => 2000,
'time_period' => 60
]
]
]
]
]
This lets legitimate high-volume partners (ERPs, WMSs) operate without triggering blanket blocks.
Custom Rate Limiting with Magento 2 Plugins
For granular, endpoint-specific throttling, implement a custom plugin on \Magento\Webapi\Controller\Rest or \Magento\GraphQl\Controller\GraphQl.
Redis-Backed Sliding Window Rate Limiter
<?php
// app/code/Vendor/ApiThrottle/Plugin/RestApiRateLimiter.php
namespace Vendor\ApiThrottle\Plugin;
use Magento\Framework\Cache\FrontendInterface;
use Magento\Webapi\Controller\Rest;
class RestApiRateLimiter
{
private FrontendInterface \$cache;
private int \$maxRequests;
private int \$windowSeconds;
public function __construct(
FrontendInterface \$cache,
int \$maxRequests = 60,
int \$windowSeconds = 60
) {
\$this->cache = \$cache;
\$this->maxRequests = \$maxRequests;
\$this->windowSeconds = \$windowSeconds;
}
public function beforeDispatch(Rest \$subject)
{
\$clientKey = \$this->getClientIdentifier();
\$endpoint = \$this->getEndpointIdentifier();
\$cacheKey = "api_limit_{\$clientKey}_{\$endpoint}";
\$requests = \$this->getSlidingWindow(\$cacheKey);
if (count(\$requests) >= \$this->maxRequests) {
throw new \Magento\Framework\Webapi\Exception(
__('Rate limit exceeded. Try again in %1 seconds.', \$this->getRetryAfter(\$requests)),
0,
\Magento\Framework\Webapi\Exception::HTTP_TOO_MANY_REQUESTS,
['Retry-After' => \$this->getRetryAfter(\$requests)]
);
}
\$requests[] = time();
\$this->cache->save(
json_encode(\$requests),
\$cacheKey,
[],
\$this->windowSeconds
);
}
private function getSlidingWindow(string \$cacheKey): array
{
\$data = \$this->cache->load(\$cacheKey);
\$requests = \$data ? json_decode(\$data, true) : [];
\$cutoff = time() - \$this->windowSeconds;
return array_filter(\$requests, fn(\$t) => \$t > \$cutoff);
}
private function getClientIdentifier(): string
{
\$ip = \$_SERVER['HTTP_X_FORWARDED_FOR'] ?? \$_SERVER['REMOTE_ADDR'] ?? 'unknown';
\$apiKey = \$_SERVER['HTTP_X_API_KEY'] ?? '';
return md5(\$ip . \$apiKey);
}
private function getEndpointIdentifier(): string
{
\$path = parse_url(\$_SERVER['REQUEST_URI'], PHP_URL_PATH);
return preg_replace('/[^a-z0-9]/', '_', strtolower(\$path));
}
private function getRetryAfter(array \$requests): int
{
if (empty(\$requests)) return \$this->windowSeconds;
\$oldest = min(\$requests);
return max(1, \$this->windowSeconds - (time() - \$oldest));
}
}
This implements a sliding window algorithm using Magento's cache (Redis-backed in production). Each client+endpoint combination gets its own counter, preventing one noisy endpoint from blocking an entire integration.
GraphQL Query Complexity Throttling
GraphQL's flexibility is its Achilles' heel: a single query can request thousands of nested objects. Limit by query complexity:
<?php
// app/code/Vendor/ApiThrottle/Plugin/GraphQlComplexityLimiter.php
namespace Vendor\ApiThrottle\Plugin;
use Magento\GraphQl\Controller\GraphQl;
class GraphQlComplexityLimiter
{
private int \$maxComplexity;
private int \$maxDepth;
public function __construct(int \$maxComplexity = 250, int \$maxDepth = 10)
{
\$this->maxComplexity = \$maxComplexity;
\$this->maxDepth = \$maxDepth;
}
public function beforeDispatch(GraphQl \$subject)
{
\$rawBody = file_get_contents('php://input');
\$query = json_decode(\$rawBody, true)['query'] ?? '';
\$complexity = \$this->calculateComplexity(\$query);
\$depth = \$this->calculateDepth(\$query);
if (\$complexity > \$this->maxComplexity) {
throw new \Magento\Framework\GraphQl\Exception\GraphQlInputException(
__(
'Query complexity %1 exceeds maximum %2. '
. 'Reduce nested fields or add pagination.',
\$complexity,
\$this->maxComplexity
)
);
}
if (\$depth > \$this->maxDepth) {
throw new \Magento\Framework\GraphQl\Exception\GraphQlInputException(
__('Query depth %1 exceeds maximum %2.', \$depth, \$this->maxDepth)
);
}
}
private function calculateComplexity(string \$query): int
{
\$complexity = 0;
// Simple heuristic: count product/category references × potential children
preg_match_all('/\\b(products|categories|items|children)\\b/', \$query, \$matches);
\$complexity = count(\$matches[0]) * 10;
// Add for pagination without pageSize
if (str_contains(\$query, 'products') && !str_contains(\$query, 'pageSize')) {
\$complexity += 50; // Penalize unbounded product queries
}
return \$complexity;
}
private function calculateDepth(string \$query): int
{
\$depth = 0;
\$maxDepth = 0;
foreach (str_split(\$query) as \$char) {
if (\$char === '{') \$depth++;
if (\$char === '}') \$depth--;
\$maxDepth = max(\$maxDepth, \$depth);
}
return \$maxDepth;
}
}
Nginx-Level Rate Limiting (First Line of Defense)
Stop abuse before it reaches PHP-FPM. Nginx limit_req is faster and more reliable than application-level throttling:
# /etc/nginx/conf.d/magento_rate_limits.conf
# Define shared memory zone: 10MB, rate 10r/s per IP
limit_req_zone \$binary_remote_addr zone=api:10m rate=10r/s;
# Stricter zone for write endpoints
limit_req_zone \$binary_remote_addr zone=api_write:10m rate=2r/s;
# Per-integration key zone (read from header or query param)
map \$http_x_api_key \$api_limit_key {
default \$binary_remote_addr;
"" \$binary_remote_addr;
~.+ \$http_x_api_key;
}
limit_req_zone \$api_limit_key zone=integration:50m rate=50r/s;
server {
# Apply to all REST endpoints
location ~ ^/rest/V1/ {
limit_req zone=api burst=20 nodelay;
# Stricter for mutations
location ~ ^/rest/V1/(orders|invoices|shipments|creditmemos)/ {
limit_req zone=api_write burst=5 nodelay;
}
# Higher limit for authenticated integrations
if (\$http_authorization ~* "Bearer") {
limit_req zone=integration burst=100 nodelay;
}
try_files \$uri \$uri/ /index.php\$is_args\$args;
}
# GraphQL endpoint
location = /graphql {
limit_req zone=api burst=30 nodelay;
# POST mutations are more expensive
if (\$request_method = POST) {
limit_req zone=api_write burst=10 nodelay;
}
try_files \$uri \$uri/ /index.php\$is_args\$args;
}
}
The nodelay parameter is critical: without it, Nginx queues excess requests instead of rejecting them immediately, which can backpressure your application during a spike.
Varnish Rate Limiting for Cached API Responses
For read-heavy REST endpoints that hit Varnish (products, categories), add rate limiting in VCL:
// /etc/varnish/default.vcl
sub vcl_recv {
# API endpoint detection
if (req.url ~ "^/rest/V1/products" || req.url ~ "^/rest/V1/categories") {
# Check rate limit header from backend
if (std.integer(req.http.X-RateLimit-Remaining, 0) <= 0) {
return (synth(429, "Too Many Requests"));
}
}
}
Alternatively, use Varnish's vmod_vsthrottle for in-Varnish rate limiting without backend roundtrips:
import vsthrottle;
sub vcl_recv {
if (req.url ~ "^/(rest|graphql)") {
if (!vsthrottle.is_allowed("api:" + client.ip, "10r/s")) {
return (synth(429, "Rate limit exceeded"));
}
}
}
Per-Customer vs. Per-IP: Choosing the Right Granularity
| Granularity | Best For | Drawback |
|---|---|---|
| Per-IP | Guest API traffic, DDoS protection | Shared NAT (offices, mobile carriers) causes false positives |
| Per-API-key | Authenticated integrations, mobile apps | Requires clients to send consistent identifiers |
| Per-customer | Logged-in storefront GraphQL | Session lookup adds latency |
| Per-endpoint | Mixed API surface (read vs. write) | More complex configuration |
For production, combine layers: Nginx per-IP for DDoS, application per-key for authenticated traffic, and per-endpoint complexity limits for GraphQL.
Monitoring and Alerting on API Abuse
Don't fly blind. Track these metrics via New Relic, Datadog, or Magento's built-in reporting:
Log Format (customize in env.php)
// app/etc/env.php
'log' => [
'handlers' => [
'api_rate_limit' => [
'type' => 'file',
'file' => '/var/log/magento/api_rate_limit.log',
'level' => \Monolog\Logger::WARNING
]
]
]
What to Alert On
- 429 responses > 1% of total API traffic — indicates overly aggressive limits
- 429 responses from single IP > 100/hour — likely scraper or attack
- GraphQL complexity rejections > 5% — mobile app or headless frontend needs optimization
- p99 API latency spike coinciding with 429s — rate limiter may be queuing instead of rejecting
Production Checklist
Before deploying rate limiting to production:
- Baseline your traffic — log current request rates per endpoint for 7 days
- Set limits 3× above peak legitimate traffic — give headroom for sales events
-
Implement graduated responses — return
429withRetry-Afterheader; don't silently drop -
Whitelist load balancers and health checks —
\$binary_remote_addrsees the LB, not the client - Test with your actual integrations — ERP sync, mobile app, headless frontend
- Monitor 429s in real-time — have a runbook to temporarily raise limits if false positives spike
- Document your limits — share rate limits with integration partners to prevent surprise blocks
Summary
Rate limiting in Magento 2 is not a single switch — it's a layered defense:
- Nginx: Cheap, fast, first line of defense against volume attacks
- Varnish: Protect cached endpoints without backend load
- Application: Granular, context-aware (per-integration, per-endpoint, per-complexity)
- Built-in WebAPI: Simple global limits as a safety net
Start with Nginx for immediate protection, then add application-level complexity limits for GraphQL, and finally tune per-integration allowances for your ERP and mobile app partners. The goal isn't to block traffic — it's to ensure a single bad actor can't ruin the experience for everyone else.
Have you implemented API throttling in your Magento 2 store? What rate limiting strategy worked best for your traffic profile?
Top comments (0)