Guide · AWS WAF

AWS WAF Rate Limiting for MCP Servers

Rate-based rules in AWS WAF v2 count requests per IP (or per custom key) over a rolling 5-minute window and block or throttle sources that exceed a configured limit. The three decisions that matter most for MCP endpoints: what to count (all requests vs. only new connection initiations — SSE clients hold persistent connections that skew counts), how to identify the originator (source IP works for ALB; for CloudFront you must use ForwardedIP with X-Forwarded-For to avoid counting CloudFront edge nodes as the client), and what action to take (BLOCK drops the request immediately; CAPTCHA challenges the client and lets humans through; COUNT logs without acting, useful for baselining before enforcement).

TL;DR

Set a rate limit of 1,000–2,000 req/5min per IP scoped to /mcp* and /api/* paths, POST method only. For CloudFront-fronted MCP servers use aggregateKeyType: FORWARDED_IP with fallbackBehavior: MATCH. Add a secondary, looser limit (10,000/5min) on GET paths to avoid blocking SSE clients. Run COUNT mode for 72 hours to baseline before switching to BLOCK.

Rate-based rule fundamentals

AWS WAF rate-based rules use a sliding 5-minute window. The limit is the maximum number of matching requests from a single aggregate key (IP or custom header value) within any 5-minute period. When a source exceeds the limit, WAF blocks subsequent requests until the count drops below the threshold in the next evaluation cycle (roughly every 30 seconds).

ParameterOptionsMCP recommendation
limit100–2,000,0001,000 on POST /mcp; 10,000 on GET /mcp (SSE)
aggregateKeyTypeIP, FORWARDED_IP, CONSTANT, CUSTOM_KEYSFORWARDED_IP for CloudFront; IP for ALB direct
actionblock, captcha, challenge, countblock for POST (unauthenticated); count first week
WindowFixed at 5 minutesCannot be changed — design limits around this
Scope-down statementAny WAF conditionScope to MCP paths + POST method to reduce noise

MCP SSE complication: An MCP client using Server-Sent Events (SSE) transport opens a single long-lived GET connection and may reconnect if dropped. The reconnect itself is a GET request; the ongoing SSE stream counts as zero additional requests after initial connection. Rate limits on GET paths with reconnect storms (e.g., 60 clients each reconnecting 10 times after a blip) can trigger limits intended for abuse. Separate rate rules for POST (tool calls) vs. GET (SSE stream) paths prevents legitimate clients from being blocked.

IP-based rate limiting for ALB-fronted MCP servers

When your MCP server is behind an Application Load Balancer, WAF sees the real client IP directly. Use aggregateKeyType: IP with a scope-down statement targeting MCP API paths.

// CDK: Rate-based rule for ALB-fronted MCP server (IP aggregation)
import * as wafv2 from "aws-cdk-lib/aws-wafv2";

const mcpPostRateLimit: wafv2.CfnWebACL.RuleProperty = {
  name: "McpPostRateLimit",
  priority: 1,
  action: { block: {} },   // switch to { count: {} } during baselining
  visibilityConfig: {
    cloudWatchMetricsEnabled: true,
    metricName: "McpPostRateLimit",
    sampledRequestsEnabled: true,
  },
  statement: {
    rateBasedStatement: {
      limit: 1000,                      // 1,000 POSTs per 5-minute window per IP
      aggregateKeyType: "IP",
      scopeDownStatement: {
        andStatement: {
          statements: [
            // Only count requests to MCP API paths
            {
              orStatement: {
                statements: [
                  {
                    byteMatchStatement: {
                      fieldToMatch: { uriPath: {} },
                      searchString: "/mcp",
                      positionalConstraint: "STARTS_WITH",
                      textTransformations: [{ priority: 0, type: "LOWERCASE" }],
                    },
                  },
                  {
                    byteMatchStatement: {
                      fieldToMatch: { uriPath: {} },
                      searchString: "/api/",
                      positionalConstraint: "STARTS_WITH",
                      textTransformations: [{ priority: 0, type: "LOWERCASE" }],
                    },
                  },
                ],
              },
            },
            // Only count POST requests (tool calls), not GET SSE connections
            {
              byteMatchStatement: {
                fieldToMatch: { method: {} },
                searchString: "POST",
                positionalConstraint: "EXACTLY",
                textTransformations: [{ priority: 0, type: "UPPERCASE" }],
              },
            },
          ],
        },
      },
    },
  },
};

// Separate, looser limit for SSE GET connections (protects against reconnect storms)
const mcpSseRateLimit: wafv2.CfnWebACL.RuleProperty = {
  name: "McpSseRateLimit",
  priority: 2,
  action: { block: {} },
  visibilityConfig: {
    cloudWatchMetricsEnabled: true,
    metricName: "McpSseRateLimit",
    sampledRequestsEnabled: true,
  },
  statement: {
    rateBasedStatement: {
      limit: 10000,                     // 10,000 GETs per 5-minute window per IP
      aggregateKeyType: "IP",
      scopeDownStatement: {
        andStatement: {
          statements: [
            {
              byteMatchStatement: {
                fieldToMatch: { uriPath: {} },
                searchString: "/mcp",
                positionalConstraint: "STARTS_WITH",
                textTransformations: [{ priority: 0, type: "LOWERCASE" }],
              },
            },
            {
              byteMatchStatement: {
                fieldToMatch: { method: {} },
                searchString: "GET",
                positionalConstraint: "EXACTLY",
                textTransformations: [{ priority: 0, type: "UPPERCASE" }],
              },
            },
          ],
        },
      },
    },
  },
};

ForwardedIP for CloudFront-fronted MCP servers

When CloudFront sits in front of your MCP server, WAF on the CloudFront distribution sees the CloudFront edge node IP as the source — not the client IP. Using aggregateKeyType: IP would apply the rate limit to the CloudFront edge node, meaning all clients behind that edge node share a single rate bucket. Use FORWARDED_IP to extract the real client IP from the X-Forwarded-For header instead.

// CDK: Rate-based rule for CloudFront-fronted MCP server (ForwardedIP)
// NOTE: This WebACL must be created in us-east-1 with scope: CLOUDFRONT

const mcpCloudFrontRateLimit: wafv2.CfnWebACL.RuleProperty = {
  name: "McpCloudFrontRateLimit",
  priority: 1,
  action: { block: {} },
  visibilityConfig: {
    cloudWatchMetricsEnabled: true,
    metricName: "McpCloudFrontRateLimit",
    sampledRequestsEnabled: true,
  },
  statement: {
    rateBasedStatement: {
      limit: 1000,
      aggregateKeyType: "FORWARDED_IP",
      forwardedIpConfig: {
        headerName: "X-Forwarded-For",
        // MATCH: block the request if X-Forwarded-For header is missing (treat as suspicious)
        // NO_MATCH: allow through if header is missing (more permissive)
        fallbackBehavior: "MATCH",
      },
      scopeDownStatement: {
        byteMatchStatement: {
          fieldToMatch: { uriPath: {} },
          searchString: "/mcp",
          positionalConstraint: "STARTS_WITH",
          textTransformations: [{ priority: 0, type: "LOWERCASE" }],
        },
      },
    },
  },
};

X-Forwarded-For format: CloudFront appends the viewer IP to the X-Forwarded-For chain. The header value is a comma-separated list like 203.0.113.1, 130.176.44.12 — the viewer IP is the first value, and CloudFront's edge IP is the last. WAF's FORWARDED_IP extractor always reads the first IP in the chain, which is correct for CloudFront. If your origin also sits behind an ALB, the XFF chain grows — verify which position holds the real client IP before setting fallbackBehavior.

Cascading rate tiers for burst protection

A single rate limit cannot cover both normal traffic variation and real abuse without either blocking legitimate clients or letting abuse through. Cascading tiers — a loose limit that applies an intermediate action plus a strict limit that blocks — give you graduated enforcement.

// Cascade: loose limit (CAPTCHA challenge) + strict limit (BLOCK)
// Both rules must be in the same WebACL; priority determines evaluation order.

// Rule 1: Strict block at 2,000 POST/5min — hard limit against automated abuse
const strictPostLimit: wafv2.CfnWebACL.RuleProperty = {
  name: "McpStrictPostLimit",
  priority: 1,
  action: { block: {} },
  visibilityConfig: { cloudWatchMetricsEnabled: true, metricName: "McpStrictPostLimit", sampledRequestsEnabled: true },
  statement: {
    rateBasedStatement: {
      limit: 2000,
      aggregateKeyType: "IP",
      scopeDownStatement: {
        byteMatchStatement: {
          fieldToMatch: { uriPath: {} },
          searchString: "/mcp",
          positionalConstraint: "STARTS_WITH",
          textTransformations: [{ priority: 0, type: "LOWERCASE" }],
        },
      },
    },
  },
};

// Rule 2: Soft limit (CAPTCHA) at 500 POST/5min — catches over-eager clients
// CAPTCHA: client must solve a visual challenge; on pass, request continues
const softPostLimit: wafv2.CfnWebACL.RuleProperty = {
  name: "McpSoftPostLimit",
  priority: 2,
  action: { captcha: {} },
  visibilityConfig: { cloudWatchMetricsEnabled: true, metricName: "McpSoftPostLimit", sampledRequestsEnabled: true },
  statement: {
    rateBasedStatement: {
      limit: 500,
      aggregateKeyType: "IP",
      scopeDownStatement: {
        byteMatchStatement: {
          fieldToMatch: { uriPath: {} },
          searchString: "/mcp",
          positionalConstraint: "STARTS_WITH",
          textTransformations: [{ priority: 0, type: "LOWERCASE" }],
        },
      },
    },
  },
};

CAPTCHA note: For non-browser MCP clients (API clients, agent frameworks), CAPTCHA is not useful — they cannot complete the visual challenge. Use CAPTCHA only if your MCP endpoint is called from a browser-based client. For API-only MCP servers, use BLOCK with a 429 custom response body instead.

// Custom 429 response body for rate-limited API clients
const strictPostLimitWithCustomResponse: wafv2.CfnWebACL.RuleProperty = {
  name: "McpStrictPostLimit",
  priority: 1,
  action: {
    block: {
      customResponse: {
        responseCode: 429,
        customResponseBodyKey: "RateLimitedBody",
      },
    },
  },
  // ... rest of rule ...
};

// In the WebACL definition, add customResponseBodies:
const webAcl = new wafv2.CfnWebACL(this, "McpWebAcl", {
  customResponseBodies: {
    RateLimitedBody: {
      contentType: "APPLICATION_JSON",
      content: JSON.stringify({
        error: "rate_limited",
        message: "Too many requests. Retry after 60 seconds.",
        retryAfter: 60,
      }),
    },
  },
  // ... rest of WebACL ...
});

CUSTOM_KEYS aggregation for per-user rate limiting

IP-based rate limits unfairly penalize users behind shared NAT (corporate offices, university networks) and allow many users behind different IPs to each consume their full budget. For authenticated MCP endpoints, aggregate by a user-specific header like a JWT claim extracted via a Cognito authorizer or a custom X-User-ID header.

// Rate limit per user ID header (for authenticated MCP endpoints)
const perUserRateLimit: wafv2.CfnWebACL.RuleProperty = {
  name: "McpPerUserRateLimit",
  priority: 3,
  action: { block: {} },
  visibilityConfig: { cloudWatchMetricsEnabled: true, metricName: "McpPerUserLimit", sampledRequestsEnabled: true },
  statement: {
    rateBasedStatement: {
      limit: 500,
      aggregateKeyType: "CUSTOM_KEYS",
      customKeys: [
        {
          header: {
            name: "X-User-ID",    // set by your auth middleware upstream
            textTransformations: [{ priority: 0, type: "NONE" }],
          },
        },
      ],
    },
  },
};

// If X-User-ID is absent (unauthenticated request), WAF falls back to IP aggregation
// for CUSTOM_KEYS rules — unauthenticated clients still get rate-limited per IP.

Baselining with COUNT mode before enforcement

Never deploy a BLOCK rate rule to production without first running it in COUNT mode to observe the distribution of request rates across your client base. A limit that looks reasonable in theory may block legitimate heavy users (CI pipelines, batch agent workflows) in practice.

# CLI: Deploy rule in COUNT mode, then query CloudWatch to see the distribution
# after 72 hours, check McpPostRateLimit metric — look at Sum and p99 dimensions

aws cloudwatch get-metric-statistics \
  --namespace AWS/WAFV2 \
  --metric-name CountedRequests \
  --dimensions Name=Rule,Value=McpPostRateLimit Name=WebACL,Value=McpWebAcl Name=Region,Value=us-east-1 \
  --start-time $(date -u -d '72 hours ago' +%Y-%m-%dT%H:%M:%SZ) \
  --end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
  --period 3600 \
  --statistics Sum Maximum \
  --output json | jq '.Datapoints | sort_by(.Timestamp)'

# If any legitimate client is hitting > 80% of your intended limit within a 5-minute window,
# raise the limit before switching from COUNT to BLOCK.

Query WAF logs in Athena to identify top IPs by count during the baseline period:

-- Athena: top 20 IPs by request count during baseline (past 72 hours)
SELECT
  httprequest.clientip AS ip,
  COUNT(*) AS request_count
FROM waf_logs
WHERE
  webaclname = 'McpWebAcl'
  AND terminatingruleid = 'McpPostRateLimit'
  AND action = 'COUNT'
  AND from_unixtime(timestamp / 1000) > current_timestamp - interval '72' hour
GROUP BY httprequest.clientip
ORDER BY request_count DESC
LIMIT 20;

Common failure modes

SymptomCauseFix
Legitimate MCP clients get 403 during peak usageRate limit too low; heavy-use clients (CI, batch agents) exceed 1,000 req/5min legitimatelyRun COUNT mode 72h; raise limit to 2× p99 observed count; add CUSTOM_KEYS rule to exempt authenticated users
CloudFront-fronted server: all clients get blocked simultaneouslyUsing aggregateKeyType: IP instead of FORWARDED_IP — all traffic from one edge node shares one bucketSwitch to FORWARDED_IP with headerName: X-Forwarded-For
Rate limit rule has no effect on abuse trafficRule priority lower than an ALLOW rule that terminates evaluation before rate rule is checked; or scope-down statement excludes the abused pathMove rate rule to priority 1; verify scope-down statement matches the actual abused path
SSE clients disconnecting every 5 minutesRate limit applies to all GET requests including SSE keepalive framesScope rate rule to POST method only; add separate high-limit GET rule
Rate limit bypassed via IPv6IPv4 limit does not apply to IPv6 source addressesAdd second rate rule with same limit scoped to IPv6; or use IPV6 in ipAddressVersion
Rate limit resets mid-attackAttacker rotating IPs faster than 5-minute window expiresAdd IP reputation managed rule group; combine rate limit with geofencing to restrict unexpected source regions