Guide · AWS Security

MCP Server WAF — AWS WAF v2 rate limiting, managed rules, bot control, IP blocking

AWS WAF v2 protects MCP server API endpoints from abuse, credential stuffing, injection attacks, and bot traffic without requiring changes to application code. Three critical WAF configuration mistakes: creating a CloudFront WebACL outside us-east-1 (CloudFront is a global service whose control plane lives in us-east-1; WAF WebACLs associated with CloudFront distributions must be created with scope CLOUDFRONT in the us-east-1 region — a WebACL created in any other region with scope REGIONAL cannot be associated with a CloudFront distribution and attempting the association returns WAFInvalidParameterException), setting rate limit too low on the MCP endpoint (MCP clients using SSE transport may hold a persistent GET connection for minutes; if the rate limit counts connection attempts rather than new requests per window, a single active SSE client can consume its rate limit budget within the window and get blocked — set rate limits on the path that handles new connection initiations, not on long-lived connection paths), and enabling the AWSManagedRulesCommonRuleSet without excluding rules that conflict with MCP JSON payloads (the SizeRestrictions_BODY rule blocks request bodies over 8KB by default — an MCP tools/call request with a large document context can exceed this, causing a 403 block; the CrossSiteScripting_BODY rule can block JSON payloads containing HTML fragments as tool arguments).

TL;DR

Create a WebACL with scope REGIONAL for ALB or CLOUDFRONT in us-east-1 for CloudFront. Add rate-based rules (1,000 requests/5 minutes per IP on /mcp and /api/*). Attach AWSManagedRulesCommonRuleSet and AWSManagedRulesKnownBadInputsRuleSet with the SizeRestrictions_BODY and CrossSiteScripting_BODY rules set to Count (not Block) initially. Enable WAF logging to S3 for audit trail.

WebACL setup and scope

A WebACL is the top-level WAF resource. Scope determines which AWS resource type it can protect: REGIONAL for ALB, API Gateway, AppSync, and Cognito User Pools; CLOUDFRONT for CloudFront distributions (must be created in us-east-1 even if your distribution serves content globally).

// CDK: WAF WebACL for ALB (REGIONAL scope, any region)
import * as wafv2 from "aws-cdk-lib/aws-wafv2";

const webAcl = new wafv2.CfnWebACL(this, "McpWebAcl", {
  scope: "REGIONAL",    // for ALB; use "CLOUDFRONT" in us-east-1 for CloudFront
  defaultAction: { allow: {} },
  visibilityConfig: {
    cloudWatchMetricsEnabled: true,
    metricName: "McpWafMetrics",
    sampledRequestsEnabled: true,
  },
  rules: [
    // 1. Rate-based rule: block IPs exceeding 1000 requests per 5 minutes
    {
      name: "RateLimitMcpApi",
      priority: 1,
      action: { block: {} },
      visibilityConfig: {
        cloudWatchMetricsEnabled: true,
        metricName: "McpRateLimit",
        sampledRequestsEnabled: true,
      },
      statement: {
        rateBasedStatement: {
          limit: 1000,                        // requests per 5-minute window per IP
          aggregateKeyType: "IP",
          scopeDownStatement: {
            orStatement: {
              statements: [
                { byteMatchStatement: { fieldToMatch: { uriPath: {} }, searchString: "/mcp", positionalConstraint: "STARTS_WITH", textTransformations: [{ priority: 0, type: "NONE" }] } },
                { byteMatchStatement: { fieldToMatch: { uriPath: {} }, searchString: "/api/", positionalConstraint: "STARTS_WITH", textTransformations: [{ priority: 0, type: "NONE" }] } },
              ],
            },
          },
        },
      },
    },
    // 2. AWS Common Rule Set (managed, free)
    {
      name: "AWSCommonRules",
      priority: 2,
      overrideAction: { none: {} },
      visibilityConfig: {
        cloudWatchMetricsEnabled: true,
        metricName: "AWSCommonRules",
        sampledRequestsEnabled: true,
      },
      statement: {
        managedRuleGroupStatement: {
          vendorName: "AWS",
          name: "AWSManagedRulesCommonRuleSet",
          excludedRules: [
            { name: "SizeRestrictions_BODY" },        // MCP tool calls may have large context payloads
            { name: "CrossSiteScripting_BODY" },      // JSON tool args may contain HTML fragments
            { name: "GenericRFI_BODY" },              // false positives on URL tool arguments
          ],
        },
      },
    },
    // 3. Known Bad Inputs Rule Set (managed, free)
    {
      name: "KnownBadInputs",
      priority: 3,
      overrideAction: { none: {} },
      visibilityConfig: {
        cloudWatchMetricsEnabled: true,
        metricName: "KnownBadInputs",
        sampledRequestsEnabled: true,
      },
      statement: {
        managedRuleGroupStatement: {
          vendorName: "AWS",
          name: "AWSManagedRulesKnownBadInputsRuleSet",
        },
      },
    },
  ],
});

// Associate WebACL with ALB
new wafv2.CfnWebACLAssociation(this, "McpWafAssociation", {
  resourceArn: alb.loadBalancerArn,
  webAclArn: webAcl.attrArn,
});

IP set blocklist and bot control

IP sets allow you to maintain a manually-managed blocklist of known bad IPs or CIDRs. Bot Control adds automated bot detection and CAPTCHA challenges for suspected bot traffic, at additional cost ($10/month + $1/million requests inspected).

Rule typeCostUse case
Rate-based ruleFree (counts toward WebACL rule limit)Limit API abuse per IP — no list to maintain
IP set match ruleFree (IP set has no additional cost)Block specific known-bad IPs or CIDRs; manage via API or console
AWSManagedRulesCommonRuleSetFree managed rule groupBlock common web exploits: XSS, SQLi, path traversal
AWSManagedRulesBotControlRuleSet$10/month + $1/1M requestsBrowser fingerprinting, CAPTCHA challenges, bot signal detection
AWSManagedRulesATPRuleSet (account takeover)$10/month + $1/1M requestsCredential stuffing protection on login endpoints
// CDK: IP set blocklist
const blocklist = new wafv2.CfnIPSet(this, "McpBlocklist", {
  scope: "REGIONAL",
  ipAddressVersion: "IPV4",
  addresses: [
    "198.51.100.0/24",   // example bad-actor CIDR
    "203.0.113.42/32",   // specific IP
  ],
});

// Rule to block the IP set (add to rules array at priority 0 — highest priority)
{
  name: "BlocklistedIPs",
  priority: 0,    // evaluated before rate limits and managed rules
  action: { block: {} },
  visibilityConfig: { cloudWatchMetricsEnabled: true, metricName: "BlocklistedIPs", sampledRequestsEnabled: true },
  statement: {
    ipSetReferenceStatement: {
      arn: blocklist.attrArn,
    },
  },
}

// Update IP set via CLI (no CDK redeploy needed)
// aws wafv2 update-ip-set --scope REGIONAL --id IPSET_ID --name McpBlocklist \
//   --lock-token $(aws wafv2 get-ip-set --scope REGIONAL --id IPSET_ID --name McpBlocklist --query LockToken --output text) \
//   --addresses '["198.51.100.0/24","203.0.113.42/32","192.0.2.100/32"]'

WAF logging and rule tuning

WAF logging records every request that matches a rule (or all requests, at higher cost). Log to S3 for long-term audit trail or to CloudWatch Logs for real-time querying. Always run new managed rule groups in Count mode (override action count) before switching to Block — this reveals false positives without impacting traffic.

// CDK: WAF logging to S3
import * as s3 from "aws-cdk-lib/aws-s3";

const wafLogsBucket = new s3.Bucket(this, "WafLogs", {
  bucketName: `aws-waf-logs-mcp-${this.account}`,   // bucket name must start with "aws-waf-logs-"
  lifecycleRules: [{ expiration: Duration.days(90) }],
  blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
  removalPolicy: RemovalPolicy.RETAIN,
});

new wafv2.CfnLoggingConfiguration(this, "WafLogging", {
  resourceArn: webAcl.attrArn,
  logDestinationConfigs: [wafLogsBucket.bucketArn],
  // Redact sensitive headers from logs
  redactedFields: [
    { singleHeader: { name: "authorization" } },
    { singleHeader: { name: "cookie" } },
  ],
});

// Athena query to find blocked requests in WAF logs:
// SELECT timestamp, httprequest.clientip, httprequest.uri, terminatingruleid
// FROM waf_logs
// WHERE action = 'BLOCK'
//   AND timestamp > to_unixtime(current_timestamp - interval '1' hour) * 1000
// ORDER BY timestamp DESC
// LIMIT 100;

Rule tuning workflow: deploy managed rules with overrideAction: { count: {} } for one week. Query WAF logs for COUNTED requests on your MCP API paths. If SizeRestrictions_BODY counts many legitimate large tool calls, keep it excluded. If CrossSiteScripting_BODY shows zero counts on your API paths, you can safely enable it as Block. Switch to overrideAction: { none: {} } (defers to each rule's individual action) once you're confident in the false-positive rate.

Common failure modes

SymptomCauseFix
WAF WebACL cannot be associated with CloudFront distributionWebACL was created with scope: REGIONAL or in a region other than us-east-1Recreate WebACL with scope: CLOUDFRONT in us-east-1; CloudFront WAFs are always global, always in us-east-1
Large MCP tool call returns 403 with no application error logSizeRestrictions_BODY rule in CommonRuleSet blocking request body over 8KBAdd SizeRestrictions_BODY to excludedRules in the managed rule group statement
Legitimate MCP client gets rate-limited during SSE reconnect stormsRate limit applies to all paths including the SSE keep-alive reconnect; one client making 6 reconnects/minute hits limitsScope rate limit down to POST requests only, or increase limit and use Cognito token bucket to enforce per-user limits instead
IP set update via CDK takes 10+ minutes and causes brief traffic interruptionCDK recreates the IP set on updates instead of modifying it in placeUse AWS CLI update-ip-set with the lock token for operational IP set changes; reserve CDK for initial creation
WAF logs not appearing in S3 bucketBucket name does not start with aws-waf-logs- (WAF enforces this prefix)Rename bucket with the required prefix or create a new bucket with the correct name
WAF passes request but ALB still returns 403WAF allow does not override ALB security group or ALB listener rules — they are evaluated independentlyCheck ALB security group allows traffic from CloudFront/internet on port 443; WAF allow only means WAF didn't block — ALB can still reject