DEV Community

Magevanta
Magevanta

Posted on Originally published at magevanta.com

Magento 2 API Rate Limiting: Protecting Your Store from Abuse

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
        ]
    ]
]
Enter fullscreen mode Exit fullscreen mode

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
                ]
            ]
        ]
    ]
]
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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"));
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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"));
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        ]
    ]
]
Enter fullscreen mode Exit fullscreen mode

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:

  1. Baseline your traffic — log current request rates per endpoint for 7 days
  2. Set limits 3× above peak legitimate traffic — give headroom for sales events
  3. Implement graduated responses — return 429 with Retry-After header; don't silently drop
  4. Whitelist load balancers and health checks\$binary_remote_addr sees the LB, not the client
  5. Test with your actual integrations — ERP sync, mobile app, headless frontend
  6. Monitor 429s in real-time — have a runbook to temporarily raise limits if false positives spike
  7. 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)