Guide · AWS WAF

AWS WAF Custom Rules for MCP Servers

Custom WAF rules let you write conditions specific to your MCP server's JSON-RPC protocol, tool schema, and client profile — things managed rule groups can't know about. Three custom rule patterns that provide meaningful protection for MCP endpoints: method injection detection (blocking requests that attempt to call tool names your server never registered, or internal administrative methods that shouldn't be externally callable), payload size enforcement at the WAF layer (enforcing a per-request size limit that matches your server's actual maximum tool payload, rather than CommonRuleSet's generic 8KB limit which is both too small for legitimate use and too large for abuse detection), and label-based multi-stage evaluation (using WAF's label system to tag a request at one rule priority, then make a final allow/block decision at another priority based on the accumulated label set — enabling logical AND across multiple independent checks without a single deeply-nested statement).

TL;DR

Write custom rules for: (1) blocking tool-method injection with ByteMatchStatement on the request body, (2) enforcing a size limit matched to your server's real max payload with SizeConstraintStatement, (3) allowing known client IP sets at priority 0 to skip all other rules, and (4) chaining labels from early rules into a final block decision. All custom rules consume WCU from your 5,000 cap — keep individual custom rule WCU low (5–50 WCU each).

Body inspection: WAF limitations for JSON-RPC

AWS WAF can inspect request bodies, but with important limitations that affect JSON-RPC rule effectiveness. Understanding these constraints determines what custom rules are feasible versus what must be handled at the application layer.

LimitationDefaultConfigurable?MCP implication
Body inspection size limit8KB for REGIONAL; 16KB for CLOUDFRONTYes — up to 64KB with additional WCU costMCP tool calls with large context may exceed 8KB; WAF only inspects first 8KB of body
JSON parsingWAF treats body as opaque bytes unless you add JSON body parsing componentYes — use jsonBody fieldToMatch with matchScopeFor reliable JSON-RPC method extraction, use jsonBody with matchScope KEYS or ALL
Regex complexityLimited regex dialect; no lookaheads, backreferencesNoSimple string matching is reliable; complex JSON structure validation must happen in application
WCU cost per body inspection ruleVariable (10–50 WCU typical)—Keep body match rules targeted and efficient; avoid wildcards in long patterns

JSON-RPC method injection detection

MCP uses JSON-RPC 2.0 format. Every request has a method field that names the operation. You can block requests containing unexpected method names — particularly internal methods that should never be called externally, or common injection probes (__proto__, system.*, admin.*).

// CDK: Block requests with unexpected JSON-RPC method names
// Uses jsonBody fieldToMatch to parse the JSON structure
const blockMethodInjection: wafv2.CfnWebACL.RuleProperty = {
  name: "McpMethodInjectionBlock",
  priority: 6,
  action: { block: {} },
  visibilityConfig: {
    cloudWatchMetricsEnabled: true,
    metricName: "McpMethodInjection",
    sampledRequestsEnabled: true,
  },
  statement: {
    orStatement: {
      statements: [
        // Block calls to internal/admin method names
        {
          byteMatchStatement: {
            fieldToMatch: {
              jsonBody: {
                matchPattern: { includedPaths: ["/method"] },  // Only inspect the "method" field
                matchScope: "VALUE",
                invalidFallbackBehavior: "MATCH",             // Block if JSON is malformed
                oversizeHandling: "MATCH",                    // Block if body exceeds inspection limit
              },
            },
            searchString: "admin.",
            positionalConstraint: "CONTAINS",
            textTransformations: [{ priority: 0, type: "LOWERCASE" }],
          },
        },
        // Block prototype pollution probes in method names
        {
          byteMatchStatement: {
            fieldToMatch: {
              jsonBody: {
                matchPattern: { includedPaths: ["/method"] },
                matchScope: "VALUE",
                invalidFallbackBehavior: "MATCH",
                oversizeHandling: "MATCH",
              },
            },
            searchString: "__proto__",
            positionalConstraint: "CONTAINS",
            textTransformations: [{ priority: 0, type: "LOWERCASE" }],
          },
        },
        // Block method names outside normal MCP protocol prefix
        // MCP methods: initialize, tools/list, tools/call, resources/list, etc.
        // Anything with "system." or "debug." is unexpected
        {
          byteMatchStatement: {
            fieldToMatch: {
              jsonBody: {
                matchPattern: { includedPaths: ["/method"] },
                matchScope: "VALUE",
                invalidFallbackBehavior: "MATCH",
                oversizeHandling: "MATCH",
              },
            },
            searchString: "system.",
            positionalConstraint: "CONTAINS",
            textTransformations: [{ priority: 0, type: "LOWERCASE" }],
          },
        },
      ],
    },
  },
};

Size constraint rules for tool call payloads

Rather than relying on CommonRuleSet's 8KB limit (which either blocks legitimate large tool calls or is disabled entirely), write a custom SizeConstraintStatement with a limit that matches your actual MCP server capacity. For example, if your largest legitimate tool call is 256KB, set the limit to 512KB to block oversized payloads while allowing real traffic.

// CDK: Custom size constraint — block request bodies over 512KB
// (Allow up to 512KB; CommonRuleSet 8KB limit is excluded in your managed rule group config)
const customSizeLimit: wafv2.CfnWebACL.RuleProperty = {
  name: "McpBodySizeLimit",
  priority: 7,
  action: {
    block: {
      customResponse: {
        responseCode: 413,
        customResponseBodyKey: "PayloadTooLargeBody",
      },
    },
  },
  visibilityConfig: {
    cloudWatchMetricsEnabled: true,
    metricName: "McpBodySizeLimit",
    sampledRequestsEnabled: true,
  },
  statement: {
    andStatement: {
      statements: [
        // Only apply to MCP API POST requests
        {
          byteMatchStatement: {
            fieldToMatch: { uriPath: {} },
            searchString: "/mcp",
            positionalConstraint: "STARTS_WITH",
            textTransformations: [{ priority: 0, type: "LOWERCASE" }],
          },
        },
        {
          byteMatchStatement: {
            fieldToMatch: { method: {} },
            searchString: "POST",
            positionalConstraint: "EXACTLY",
            textTransformations: [{ priority: 0, type: "UPPERCASE" }],
          },
        },
        // Block if body size exceeds 512KB
        {
          sizeConstraintStatement: {
            fieldToMatch: { body: { oversizeHandling: "MATCH" } },  // MATCH = treat oversized as matching
            comparisonOperator: "GT",
            size: 524288,    // 512KB in bytes
            textTransformations: [{ priority: 0, type: "NONE" }],
          },
        },
      ],
    },
  },
};

Oversize handling: The oversizeHandling field on body fieldToMatch determines what WAF does when the body exceeds the inspection limit (8KB default, 64KB maximum). MATCH treats the request as if it matched the rule condition — for a block rule, this means oversized bodies are blocked. NO_MATCH treats them as not matching — oversized bodies pass through without inspection. CONTINUE inspects only the portion that fits in the inspection limit. For security-oriented size limit rules, use MATCH.

IP set allowlists for trusted MCP clients

If you have known trusted callers — a specific Claude.ai deployment, your own agent infrastructure, or a partner's integration — add their source IPs to an allowlist IP set and exempt them from all other WAF rules at priority 0. An allow action terminates WAF rule evaluation; no other rules run for that request.

// CDK: IP set allowlist for trusted MCP clients (e.g., internal agent infrastructure)
const trustedClientIpSet = new wafv2.CfnIPSet(this, "TrustedMcpClients", {
  scope: "REGIONAL",
  ipAddressVersion: "IPV4",
  addresses: [
    "10.0.1.0/24",       // internal agent infra VPC CIDR
    "203.0.113.100/32",  // CI/CD pipeline egress IP
  ],
});

const allowTrustedClients: wafv2.CfnWebACL.RuleProperty = {
  name: "AllowTrustedMcpClients",
  priority: 0,   // Highest priority — allow terminates evaluation; no other rules run
  action: { allow: {} },
  visibilityConfig: {
    cloudWatchMetricsEnabled: true,
    metricName: "AllowedTrustedClients",
    sampledRequestsEnabled: true,
  },
  statement: {
    ipSetReferenceStatement: {
      arn: trustedClientIpSet.attrArn,
    },
  },
};

// Update trusted IPs via CLI without CDK redeployment (use lock token for safe update):
// LOCK_TOKEN=$(aws wafv2 get-ip-set --scope REGIONAL --id $IPSET_ID --name TrustedMcpClients --query LockToken --output text)
// aws wafv2 update-ip-set --scope REGIONAL --id $IPSET_ID --name TrustedMcpClients \
//   --lock-token $LOCK_TOKEN \
//   --addresses '["10.0.1.0/24","203.0.113.100/32","198.51.100.50/32"]'

Label-based multi-stage rule chaining

WAF rules can emit custom labels, and subsequent rules can match on those labels. This enables logical AND across multiple independent rule conditions without a single giant nested AND statement — improving readability and making individual conditions easier to update.

// Pattern: "suspicious if BOTH geo is high-risk AND no auth header"
// Step 1: Label rule — emit a label if geo is high-risk (COUNT, not block)
const labelHighRiskGeo: wafv2.CfnWebACL.RuleProperty = {
  name: "LabelHighRiskGeo",
  priority: 8,
  action: {
    count: {},
    // Note: to emit a label AND count, use ruleLabels in the rule body
  },
  ruleLabels: [
    { name: "mcp:threat:high-risk-geo" },  // Custom label — prefix must be your own namespace
  ],
  visibilityConfig: { cloudWatchMetricsEnabled: true, metricName: "LabelHighRiskGeo", sampledRequestsEnabled: true },
  statement: {
    geoMatchStatement: {
      countryCodes: ["XX", "YY"],   // Your high-risk geo set
    },
  },
};

// Step 2: Label rule — emit a label if no Authorization header (unauthenticated)
const labelUnauthenticated: wafv2.CfnWebACL.RuleProperty = {
  name: "LabelUnauthenticated",
  priority: 9,
  action: { count: {} },
  ruleLabels: [
    { name: "mcp:threat:unauthenticated" },
  ],
  visibilityConfig: { cloudWatchMetricsEnabled: true, metricName: "LabelUnauthenticated", sampledRequestsEnabled: true },
  statement: {
    notStatement: {
      statement: {
        byteMatchStatement: {
          fieldToMatch: { singleHeader: { name: "authorization" } },
          searchString: "Bearer ",
          positionalConstraint: "STARTS_WITH",
          textTransformations: [{ priority: 0, type: "NONE" }],
        },
      },
    },
  },
};

// Step 3: Final rule — block if BOTH labels are present
const blockHighRiskUnauthenticated: wafv2.CfnWebACL.RuleProperty = {
  name: "BlockHighRiskUnauthenticated",
  priority: 25,   // After managed rules; evaluates accumulated labels
  action: { block: {} },
  visibilityConfig: { cloudWatchMetricsEnabled: true, metricName: "BlockHighRiskUnauth", sampledRequestsEnabled: true },
  statement: {
    andStatement: {
      statements: [
        {
          labelMatchStatement: {
            scope: "LABEL",
            key: "mcp:threat:high-risk-geo",
          },
        },
        {
          labelMatchStatement: {
            scope: "LABEL",
            key: "mcp:threat:unauthenticated",
          },
        },
      ],
    },
  },
};

Label namespace: Custom labels must use a namespace:name format. AWS-managed rule labels use awswaf:managed: prefix; your custom labels should use a product-specific prefix (e.g., mcp:) to avoid collisions. Labels exist only for the duration of the request evaluation — they don't persist.

Custom response bodies for developer-friendly errors

By default, WAF blocked requests return an HTML 403 page. For MCP API clients (programmatic callers), an HTML response is confusing and hard to parse. Add custom response bodies to return JSON errors with useful diagnostics.

// CDK: Define custom response bodies on the WebACL
const webAcl = new wafv2.CfnWebACL(this, "McpWebAcl", {
  customResponseBodies: {
    RateLimited: {
      contentType: "APPLICATION_JSON",
      content: JSON.stringify({
        jsonrpc: "2.0",
        error: {
          code: -32429,
          message: "Rate limit exceeded. Please retry after 60 seconds.",
          data: { retryAfter: 60, documentation: "https://alivemcp.com/docs/rate-limits" },
        },
        id: null,
      }),
    },
    PayloadTooLarge: {
      contentType: "APPLICATION_JSON",
      content: JSON.stringify({
        jsonrpc: "2.0",
        error: {
          code: -32700,
          message: "Request payload too large. Maximum allowed size is 512KB.",
          data: { maxSizeBytes: 524288 },
        },
        id: null,
      }),
    },
    GeoBlocked: {
      contentType: "APPLICATION_JSON",
      content: JSON.stringify({
        jsonrpc: "2.0",
        error: {
          code: -32403,
          message: "Access not available in your region.",
          data: { documentation: "https://alivemcp.com/docs/availability" },
        },
        id: null,
      }),
    },
  },
  // ... rest of WebACL ...
});

Common failure modes

SymptomCauseFix
JSON body match rule never triggers even when method name matchesBody exceeds WAF's 8KB inspection limit; only first 8KB inspected; method field is beyond that offset in large payloadsSet oversizeHandling: MATCH on body fieldToMatch; increase inspection limit to 64KB (increases WCU cost)
Custom label not matched by downstream label-match ruleLabel emitting rule uses count action without ruleLabels field; or label emitting rule priority is HIGHER number than label-match rule (evaluated after)Verify ruleLabels is set on the emitting rule; ensure emitting rule has lower priority number (evaluated first)
IP set allow rule doesn't bypass subsequent rulesAllow action terminates evaluation only if the IP matches; if the IP set is wrong or stale, rule doesn't fireVerify IP set contents via aws wafv2 get-ip-set; check WAF logs for AllowedTrustedClients metric count
Size constraint rule blocks large but valid tool payloadsLimit set too low; legitimate document-context tool calls exceed the thresholdBaseline actual payload sizes from ALB access logs (bytes_received field) before setting limit; set to 2× p99
Method injection rule causes false positives on URL tool argumentsTool argument value (not method field) contains string "admin." or similar; jsonBody match inspecting VALUE scope matches all JSON values, not just /methodUse includedPaths: ["/method"] in jsonBody matchPattern to restrict to only the method field