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.
| Limitation | Default | Configurable? | MCP implication |
|---|---|---|---|
| Body inspection size limit | 8KB for REGIONAL; 16KB for CLOUDFRONT | Yes — up to 64KB with additional WCU cost | MCP tool calls with large context may exceed 8KB; WAF only inspects first 8KB of body |
| JSON parsing | WAF treats body as opaque bytes unless you add JSON body parsing component | Yes — use jsonBody fieldToMatch with matchScope | For reliable JSON-RPC method extraction, use jsonBody with matchScope KEYS or ALL |
| Regex complexity | Limited regex dialect; no lookaheads, backreferences | No | Simple string matching is reliable; complex JSON structure validation must happen in application |
| WCU cost per body inspection rule | Variable (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
| Symptom | Cause | Fix |
|---|---|---|
| JSON body match rule never triggers even when method name matches | Body exceeds WAF's 8KB inspection limit; only first 8KB inspected; method field is beyond that offset in large payloads | Set oversizeHandling: MATCH on body fieldToMatch; increase inspection limit to 64KB (increases WCU cost) |
| Custom label not matched by downstream label-match rule | Label 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 rules | Allow action terminates evaluation only if the IP matches; if the IP set is wrong or stale, rule doesn't fire | Verify IP set contents via aws wafv2 get-ip-set; check WAF logs for AllowedTrustedClients metric count |
| Size constraint rule blocks large but valid tool payloads | Limit set too low; legitimate document-context tool calls exceed the threshold | Baseline 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 arguments | Tool argument value (not method field) contains string "admin." or similar; jsonBody match inspecting VALUE scope matches all JSON values, not just /method | Use includedPaths: ["/method"] in jsonBody matchPattern to restrict to only the method field |