Deep Dive · AWS WAF
AWS WAF for MCP Servers: Three Patterns for API-Aware Protection
Applying AWS WAF to an MCP server endpoint is not the same as protecting a traditional web application. Your legitimate callers are bots. Tool calls carry large JSON-RPC payloads that exceed default body size limits. Server-Sent Events hold long-lived GET connections that look like slow-rate attacks. And the moment you enable Bot Control on your API path, you block every AI agent that connects to your server — because CAPTCHA and JavaScript challenges require a browser. This post synthesizes five WAF layers — rate-based rules, managed rule groups, bot control, geo rules, and custom rules — around three structural patterns: the AI-bot exemption problem, CommonRuleSet tuning for JSON-RPC, and a prioritized defense-in-depth stack with label chaining.
Why WAF for MCP is different: the attack surface table
Before reaching for WAF rules, it helps to map the specific threat classes an MCP server faces and which WAF primitive addresses each. The threat surface is different from a web app: there is no session cookie to steal, no HTML form to spam, and no human user who can complete a CAPTCHA. What you have instead is a JSON-RPC API endpoint that unauthenticated callers can invoke, a GET endpoint that holds persistent SSE connections, and tool call bodies that can legitimately be several hundred kilobytes.
| Threat class | Example trigger | WAF primitive | Key complication |
|---|---|---|---|
| Volumetric POST abuse | Attacker floods /mcp POST endpoint with tool calls to exhaust compute |
Rate-based rule on POST, scoped to /mcp* |
SSE GET connections must be limited separately — they have different count semantics |
| Known-bad-actor IPs | Botnet IPs, Tor exit nodes, known scanner infrastructure | IP reputation list (free, 25 WCU) | Runs early at priority 1 — cheapest filtering pass before expensive rules |
| Web injection attacks | Log4j JNDI injection, SSRF via tool arguments, path traversal in method names | KnownBadInputsRuleSet + custom byte match | CommonRuleSet body rules cause false positives on valid MCP payloads |
| Automated scraping of browser UI | Status dashboard scraped by headless browsers, OAuth forms stuffed | Bot Control scoped to /, /auth/*, /dashboard/* |
CAPTCHA and Challenge actions block all API clients — must never apply to /mcp* |
| Sanctioned-country compliance | Request from OFAC-sanctioned country must not be processed | Geo block at priority 2 | CloudFront WAF has better geo accuracy than ALB WAF (TLS at edge vs proxy IP) |
| JSON-RPC method injection | Tool call with method: "admin.reset" or method: "__proto__.polluted" |
Custom byte match on jsonBody → /method field |
CommonRuleSet does not inspect JSON sub-fields — requires custom rule |
You need all six. Each addresses a different attack class and they don't overlap: the IP reputation list doesn't know about JSON-RPC method injection, and the method injection rule doesn't know about botnet IPs. Skipping any one layer leaves a gap. The rest of this guide walks through three structural patterns that determine how these primitives compose.
Pattern 1: The AI-bot exemption problem
The most dangerous WAF misconfiguration for an MCP server is applying Bot Control with CAPTCHA or Challenge action to the MCP API endpoint. Both actions require a JavaScript-capable browser. Claude, GPT, and every agent framework that calls your MCP server programmatically cannot complete a CAPTCHA, cannot run a JavaScript silent challenge, and has no mechanism to acquire or send the aws-waf-token cookie that WAF issues on a successful challenge. The result is every legitimate API caller gets blocked with a 405 redirect or a challenge page their runtime cannot parse.
The root cause is that AWS WAF's bot taxonomy was built for web applications where "bots" means scrapers, credential stuffers, and fake account creators. For MCP servers, the most important callers — AI agent frameworks — look indistinguishable from bots by every WAF metric: no browser, no cookies, no mouse movements, automated and high-frequency.
The correct pattern has two parts.
Part A: Scope Bot Control to browser paths only
Use a scope-down statement with a NOT ByteMatch to exclude MCP API paths from Bot Control inspection entirely. The rule runs only when the request path does NOT match /mcp or /api.
// CDK: Bot Control scoped to browser paths, never API paths
new wafv2.CfnWebACL(this, 'McpWebACL', {
rules: [
{
name: 'BotControlBrowserOnly',
priority: 15,
statement: {
managedRuleGroupStatement: {
vendorName: 'AWS',
name: 'AWSManagedRulesBotControlRuleSet',
managedRuleGroupConfigs: [
{ awsManagedRulesBotControlRuleSet: { inspectionLevel: 'COMMON' } }
],
// Scope-down: only inspect requests that are NOT /mcp* AND NOT /api/*
scopeDownStatement: {
notStatement: {
statement: {
orStatement: {
statements: [
{
byteMatchStatement: {
searchString: '/mcp',
fieldToMatch: { uriPath: {} },
textTransformations: [{ priority: 0, type: 'NONE' }],
positionalConstraint: 'STARTS_WITH',
},
},
{
byteMatchStatement: {
searchString: '/api/',
fieldToMatch: { uriPath: {} },
textTransformations: [{ priority: 0, type: 'NONE' }],
positionalConstraint: 'STARTS_WITH',
},
},
],
},
},
},
},
},
},
overrideAction: { none: {} },
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: 'BotControlBrowserOnly',
sampledRequestsEnabled: true,
},
},
],
});
With this scope-down, Bot Control never inspects /mcp* or /api/* paths at all. The rule's CAPTCHA and block actions only fire on browser-facing paths (/, /auth/*, /dashboard/*, /status/*) where legitimate users are expected to have browsers.
Part B: Allow known AI agent user-agents before any blocking rule
Even with the scope-down in place, it's worth adding an explicit allow rule at priority 14 (just before Bot Control at priority 15) for known AI agent user-agent strings. This allow rule terminates evaluation — if the request matches, it bypasses all subsequent rules including Bot Control, rate limits, and managed rules. This is useful for uptime monitors (like AliveMCP's own pinger) and for AI clients that send a well-known user-agent string.
// Priority 14: Allow known AI agent user-agents — evaluation stops here
{
name: 'AllowAIAgentUserAgents',
priority: 14,
statement: {
orStatement: {
statements: [
// Claude agent frameworks
{
byteMatchStatement: {
searchString: 'Claude-',
fieldToMatch: { singleHeader: { name: 'user-agent' } },
textTransformations: [{ priority: 0, type: 'NONE' }],
positionalConstraint: 'CONTAINS',
},
},
// OpenAI agent framework
{
byteMatchStatement: {
searchString: 'ChatGPT-User',
fieldToMatch: { singleHeader: { name: 'user-agent' } },
textTransformations: [{ priority: 0, type: 'NONE' }],
positionalConstraint: 'CONTAINS',
},
},
// modelcontextprotocol SDK default user-agent
{
byteMatchStatement: {
searchString: 'modelcontextprotocol',
fieldToMatch: { singleHeader: { name: 'user-agent' } },
textTransformations: [{ priority: 0, type: 'LOWERCASE' }],
positionalConstraint: 'CONTAINS',
},
},
// AliveMCP uptime pinger
{
byteMatchStatement: {
searchString: 'AliveMCP',
fieldToMatch: { singleHeader: { name: 'user-agent' } },
textTransformations: [{ priority: 0, type: 'NONE' }],
positionalConstraint: 'CONTAINS',
},
},
],
},
},
action: { allow: {} },
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: 'AllowAIAgentUserAgents',
sampledRequestsEnabled: true,
},
},
Use this rule conservatively. User-agent strings are trivially spoofable, so treating this as a security boundary would be a mistake. Its purpose is operational: ensuring that known-good clients are never accidentally caught by a misconfigured rule, and that your uptime monitors always get through. For actual access control, use authenticated API keys and route them through the trusted IP set at priority 0.
Getting bot signals without breaking clients: COUNT mode with label-match rules
If you want visibility into Bot Control's classification decisions for API-path traffic without applying any blocking action, use a separate label-match rule. Configure Bot Control to apply ONLY to browser paths (via scope-down) but add a COUNT-only rule at an earlier priority that emits bot labels for API-path requests:
// Priority 13: COUNT-only bot signal logger for API paths (no blocking action)
// Emits bot:category:* labels visible in CloudWatch Logs Insights without blocking
{
name: 'BotSignalAPIPathAudit',
priority: 13,
statement: {
managedRuleGroupStatement: {
vendorName: 'AWS',
name: 'AWSManagedRulesBotControlRuleSet',
managedRuleGroupConfigs: [
{ awsManagedRulesBotControlRuleSet: { inspectionLevel: 'COMMON' } }
],
scopeDownStatement: {
byteMatchStatement: {
searchString: '/mcp',
fieldToMatch: { uriPath: {} },
textTransformations: [{ priority: 0, type: 'NONE' }],
positionalConstraint: 'STARTS_WITH',
},
},
},
},
overrideAction: { count: {} }, // Never blocks — logs only
visibilityConfig: { /* ... */ },
},
The overrideAction: { count: {} } on a managed rule group overrides ALL rules within the group to COUNT mode. Bot Control still emits its awswaf:managed:aws:bot-control:bot:category:* labels into the log record, and you can query them in CloudWatch Logs Insights to understand what fraction of your API traffic looks bot-like — without the risk of blocking legitimate AI clients.
Pattern 2: CommonRuleSet tuning for JSON-RPC
AWSManagedRulesCommonRuleSet is the first managed rule group most teams attach. It covers XSS, SQL injection, path traversal, and request size abuse across body, headers, query string, and URI. For traditional web apps, you can attach it with no exclusions and get meaningful protection. For MCP servers, three rules in CommonRuleSet will cause false positives on every legitimate tool call — and those false positives will block your users silently if you don't catch them in COUNT mode first.
The three CommonRuleSet exclusions every MCP server needs
| Rule name | What it blocks | Why MCP breaks it | Mitigation |
|---|---|---|---|
SizeRestrictions_BODY |
Request bodies over 8KB | MCP tool calls carry full context payloads — a summarize-document tool or a web-fetch tool can legitimately send several hundred KB of body | Exclude from CommonRuleSet; replace with custom SizeConstraintStatement at 512KB |
CrossSiteScripting_BODY |
JSON bodies containing HTML-like content (<script>, javascript: URIs, event handlers) |
HTML-sanitization tools, web-scraping tools, and link-preview tools all legitimately pass HTML content as tool arguments; the sanitization tool's whole job is to process potentially dangerous HTML | Exclude from CommonRuleSet; validate jsonrpc field schema instead at application layer |
GenericRFI_BODY |
Request bodies containing URL-like values (Remote File Inclusion detection) | Web-fetch tools, link-preview tools, and any tool that takes a URL as an argument pass full URLs in the JSON-RPC params field — exactly what RFI detection triggers on |
Exclude from CommonRuleSet; validate URL schemes at application layer (http:// and https:// only) |
These three exclusions are not optional hardening — they are correctness requirements. Running CommonRuleSet without them on a production MCP endpoint means your WAF will block valid tool calls intermittently based on payload content, producing 403 errors that your users will interpret as application bugs.
How to add exclusions in CDK
// CommonRuleSet with three MCP-specific exclusions
{
name: 'CommonRuleSetMCPTuned',
priority: 20,
statement: {
managedRuleGroupStatement: {
vendorName: 'AWS',
name: 'AWSManagedRulesCommonRuleSet',
excludedRules: [
{ name: 'SizeRestrictions_BODY' }, // Replace with 512KB custom rule
{ name: 'CrossSiteScripting_BODY' }, // False positive: HTML tool args
{ name: 'GenericRFI_BODY' }, // False positive: URL tool args
],
},
},
overrideAction: { none: {} },
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: 'CommonRuleSetMCPTuned',
sampledRequestsEnabled: true,
},
},
Exclusions in a managed rule group convert those specific rules to COUNT mode — they still log matches but do not block. This is useful: you can see in CloudWatch whether the excluded rules would have fired, which tells you how often tool calls carry the patterns they detect. If GenericRFI_BODY match count is near-zero, your clients aren't sending URL arguments and you might not actually need the exclusion; if it's high, you definitely do.
Replacing SizeRestrictions_BODY with a custom 512KB limit
Excluding SizeRestrictions_BODY means you have no body size enforcement at all unless you add a custom rule. The 8KB default is too small for MCP, but you still want a ceiling to prevent attackers from sending arbitrarily large payloads. Set it at 512KB — generous enough for any legitimate tool call payload, small enough to prevent memory exhaustion attacks on your application server.
// Priority 30: Custom body size constraint — 512KB ceiling
// (Replaces excluded CommonRuleSet SizeRestrictions_BODY which enforced 8KB)
{
name: 'MCPBodySizeLimit',
priority: 30,
statement: {
sizeConstraintStatement: {
fieldToMatch: { body: { oversizeHandling: 'MATCH' } },
comparisonOperator: 'GT',
size: 524288, // 512KB
textTransformations: [{ priority: 0, type: 'NONE' }],
},
},
action: {
block: {
customResponse: {
responseCode: 413,
customResponseBodyKey: 'json_rpc_payload_too_large',
},
},
},
visibilityConfig: { /* ... */ },
},
The oversizeHandling: 'MATCH' setting means requests with bodies that WAF cannot fully inspect (above WAF's own 64KB inspection limit) are treated as matching the rule — they will be blocked. For a 512KB ceiling this is correct behavior: if WAF can't inspect it, the body is large enough to block regardless.
The custom response uses JSON-RPC error format rather than the default HTML error page. API clients that cannot parse HTML will otherwise log a confusing 413 error with an unreadable body. Define the custom response body in the WebACL's customResponseBodies block:
customResponseBodies: {
json_rpc_payload_too_large: {
contentType: 'APPLICATION_JSON',
content: JSON.stringify({
jsonrpc: '2.0',
id: null,
error: { code: -32700, message: 'Parse error: request payload too large (max 512KB)' },
}),
},
json_rpc_rate_limited: {
contentType: 'APPLICATION_JSON',
content: JSON.stringify({
jsonrpc: '2.0',
id: null,
error: { code: -32429, message: 'Too many requests — rate limit exceeded' },
}),
},
json_rpc_geo_blocked: {
contentType: 'APPLICATION_JSON',
content: JSON.stringify({
jsonrpc: '2.0',
id: null,
error: { code: -32403, message: 'Access denied — this service is not available in your region' },
}),
},
},
JSON-RPC 2.0 doesn't define HTTP-layer error codes (-32429, -32403 are custom extensions), but using the JSON-RPC error envelope means clients that parse the body get meaningful error messages regardless of the HTTP status code.
Pattern 3: Defense-in-depth with priority ordering and label chaining
A WebACL evaluates rules in priority order (lowest number first) and stops at the first terminating action (BLOCK, ALLOW, CAPTCHA). COUNT rules never terminate — they accumulate labels and continue evaluation. This means priority order is load-bearing: a cheap IP reputation check at priority 1 blocks known-bad traffic before it reaches the expensive managed rule groups at priority 20+, which keeps WCU consumption proportional to actual good traffic volume rather than total traffic volume.
The complete priority stack for an MCP server WebACL:
| Priority | Rule name | Action | WCU | Purpose |
|---|---|---|---|---|
| 0 | TrustedIPAllowList | ALLOW (terminates) | 1 | CI/CD IPs, uptime monitors — skip all rules |
| 1 | AWSManagedRulesAmazonIpReputationList | BLOCK | 25 | Known botnet IPs, scanner infrastructure — free |
| 2 | OFACGeoBlock | BLOCK | 1 | Sanctions compliance — OFAC country list |
| 3 | GlobalRateLimitMCPPost | BLOCK | 2 | 1,000 POST/5min per IP — scope to /mcp* POST only |
| 4 | GeoAmplifiedRateLimit | BLOCK | 2 | 200 POST/5min for high-abuse regions — stricter tier |
| 5 | SSERateLimitMCPGet | BLOCK | 2 | 10,000 GET/5min per IP — SSE reconnect storms |
| 6 | EmitGeoLabel | COUNT (never terminates) | 1 | Tag high-risk-geo requests with custom label for later use |
| 7 | EmitUnauthLabel | COUNT (never terminates) | 2 | Tag requests without Authorization header with custom label |
| 10 | AWSManagedRulesKnownBadInputsRuleSet | BLOCK | 200 | Log4j JNDI, SSRF patterns, path traversal |
| 13 | BotSignalAPIPathAudit | COUNT (override) | 50 | Emit bot labels on API paths — logging only, never blocks |
| 14 | AllowAIAgentUserAgents | ALLOW (terminates) | 2 | Known AI agent user-agents — bypass remaining rules |
| 15 | BotControlBrowserOnly | BLOCK/CAPTCHA | 50 | Bot Control scoped to browser paths — never /mcp* |
| 20 | CommonRuleSetMCPTuned | BLOCK | 700 | CommonRuleSet minus 3 MCP exclusions |
| 25 | LabelChainHighRiskUnauthBlock | BLOCK | 1 | AND: high-risk-geo label + unauthenticated label → block |
| 30 | MCPMethodInjectionBlock | BLOCK | 5 | JSON body /method field — admin., __proto__, system. prefix match |
| 31 | MCPBodySizeLimit | BLOCK | 1 | Custom 512KB body size ceiling — replaces excluded SizeRestrictions_BODY |
Total WCU: approximately 1,045 out of a 5,000 cap — plenty of headroom for additional custom rules without approaching the limit.
Priority 0: The trusted IP allow-list
Always put your CI/CD pipeline IPs, your uptime monitor IPs, and your own office egress IPs in a WAF IP set and allow them at priority 0. An ALLOW action terminates evaluation — requests from these IPs skip every subsequent rule, including rate limits. This prevents AliveMCP's health checks, your own deployment scripts, and your team's manual curl tests from being blocked by your own WAF rules, which would produce confusing false alerts.
// Priority 0: Trusted IP allow-list — bypasses all other rules
// Add: CI/CD egress IPs, AliveMCP uptime monitor IP ranges, VPN/office IPs
const trustedIPSet = new wafv2.CfnIPSet(this, 'TrustedIPSet', {
name: 'mcp-trusted-ips',
scope: 'CLOUDFRONT', // or 'REGIONAL' for ALB
ipAddressVersion: 'IPV4',
addresses: [
'203.0.113.10/32', // Example: CI/CD runner
'198.51.100.0/24', // Example: AliveMCP monitor range
],
});
// Rule using this IP set
{
name: 'TrustedIPAllowList',
priority: 0,
statement: {
ipSetReferenceStatement: { arn: trustedIPSet.attrArn },
},
action: { allow: {} },
visibilityConfig: { /* ... */ },
},
Priority 6–7: Label-emitting COUNT rules
Label chaining is the WAF pattern that lets you combine signals from multiple earlier rules into a single late-priority blocking decision. COUNT rules can emit labels using the ruleLabels field without taking any blocking action. A later rule then matches on the presence of multiple labels simultaneously — an AND condition — to block only traffic that meets all criteria.
In the priority stack above, priority 6 emits mcp:high-risk-geo for requests from high-abuse countries, and priority 7 emits mcp:unauthenticated for requests without an Authorization header. Priority 25 then blocks only requests that carry BOTH labels: unauthenticated traffic from high-risk regions. Authenticated API keys get through even from those regions; anonymous traffic from low-risk regions also passes. Only the intersection — unauthenticated + high-risk-geo — is blocked.
// Priority 6: Emit geo-risk label (COUNT only — does not block)
{
name: 'EmitGeoLabel',
priority: 6,
statement: {
geoMatchStatement: {
countryCodes: ['RU', 'CN', 'KP', 'IR', 'BY'], // High-abuse regions
},
},
action: {
count: {
customRequestHandling: {
insertHeaders: [],
},
},
},
ruleLabels: [{ name: 'mcp:high-risk-geo' }],
visibilityConfig: { /* ... */ },
},
// Priority 7: Emit unauthenticated label (COUNT only)
{
name: 'EmitUnauthLabel',
priority: 7,
statement: {
notStatement: {
statement: {
byteMatchStatement: {
searchString: 'Bearer ',
fieldToMatch: { singleHeader: { name: 'authorization' } },
textTransformations: [{ priority: 0, type: 'NONE' }],
positionalConstraint: 'STARTS_WITH',
},
},
},
},
action: { count: {} },
ruleLabels: [{ name: 'mcp:unauthenticated' }],
visibilityConfig: { /* ... */ },
},
// Priority 25: Block intersection of both labels (AND condition)
{
name: 'LabelChainHighRiskUnauthBlock',
priority: 25,
statement: {
andStatement: {
statements: [
{
labelMatchStatement: {
scope: 'LABEL',
key: 'mcp:high-risk-geo',
},
},
{
labelMatchStatement: {
scope: 'LABEL',
key: 'mcp:unauthenticated',
},
},
],
},
},
action: {
block: {
customResponse: {
responseCode: 403,
customResponseBodyKey: 'json_rpc_geo_blocked',
},
},
},
visibilityConfig: { /* ... */ },
},
Label chaining keeps each individual signal rule simple (one condition each) while enabling compound blocking logic without writing complex nested WAF statements. Adding a new signal is just adding another COUNT rule that emits a label; the blocking rule at priority 25 can be extended with additional label conditions without touching the signal rules.
Priority 2: OFAC geo block vs geo-amplified rate limiting
The priority-2 OFAC block and the priority-4 geo-amplified rate limit address different requirements. The OFAC block is a hard compliance requirement: requests from OFAC-sanctioned countries must be rejected entirely, regardless of authentication status or traffic volume. It is a hard BLOCK with no exceptions.
The geo-amplified rate limit is a soft enforcement measure for high-abuse regions. Rather than blocking all traffic from those regions, it applies a stricter rate limit — 200 requests per 5 minutes instead of 1,000. Authenticated developers in those regions can still use the API at reasonable rates; only unusually high-volume sources get blocked. This is a better tradeoff than a hard geo block for regions where blocking is not a compliance requirement.
// Priority 4: Geo-amplified rate limit — 5× stricter for high-abuse regions
// Separate from OFAC compliance block — this is abuse prevention, not compliance
{
name: 'GeoAmplifiedRateLimit',
priority: 4,
statement: {
rateBasedStatement: {
limit: 200, // vs 1,000 for global limit at priority 3
aggregateKeyType: 'IP',
scopeDownStatement: {
andStatement: {
statements: [
{
geoMatchStatement: {
countryCodes: ['RU', 'CN', 'KP', 'IR'],
},
},
{
byteMatchStatement: {
searchString: '/mcp',
fieldToMatch: { uriPath: {} },
textTransformations: [{ priority: 0, type: 'NONE' }],
positionalConstraint: 'STARTS_WITH',
},
},
],
},
},
},
},
action: {
block: {
customResponse: {
responseCode: 429,
customResponseBodyKey: 'json_rpc_rate_limited',
},
},
},
visibilityConfig: { /* ... */ },
},
The rate-limiting SSE problem in detail
Server-Sent Events deserve special treatment because their traffic profile looks like abuse by every standard WAF metric. An SSE client that opens a connection to /mcp (GET) holds that connection open for minutes or hours. If that client drops and reconnects — due to a Lambda cold start, a deploy, or a network blip — it will open a new GET request immediately. If you have 60 MCP clients all reconnecting simultaneously after a brief outage, you get 60 GET requests in under a second, which will trip any sensible per-IP rate limit designed to block GET flood attacks.
The correct approach is to rate-limit POST and GET with separate rules, using different limits calibrated to their different traffic semantics:
- POST limit (priority 3): 1,000 requests per 5 minutes. POST is where tool calls happen — each POST is a meaningful API operation. 1,000 tool calls per 5 minutes is roughly 3.3 per second, which is generous for any legitimate use case.
- GET limit (priority 5): 10,000 requests per 5 minutes. GET is for SSE connection establishment and reconnection. At 10,000 per 5 minutes (33/second), this allows for very high reconnect rates without triggering on normal traffic.
For CloudFront-fronted MCP servers, use aggregateKeyType: FORWARDED_IP with the appropriate header configuration. CloudFront edge nodes themselves originate the connection to your origin, so WAF at the origin ALB will see CloudFront IPs rather than client IPs unless you extract the X-Forwarded-For header. Configure fallbackBehavior: MATCH so that requests without a forwarded IP header are treated as matching the rate limit (fail closed).
Method injection detection: the one custom rule CommonRuleSet misses
CommonRuleSet's injection detection is designed for SQL injection in query parameters and XSS in form fields. It does not inspect JSON body sub-fields. An attacker who discovers your MCP server can send a tool call with a crafted method field: "method": "admin.reset", "method": "__proto__.polluted", or "method": "system.exec". If your MCP server router does any kind of dynamic method dispatch that doesn't strict-equal against an allowlist, this is an injection vector.
The fix is a custom ByteMatchStatement that inspects the JSON body's /method field specifically:
// Priority 30: JSON-RPC method injection detection
// Blocks method values starting with dangerous prefixes
{
name: 'MCPMethodInjectionBlock',
priority: 30,
statement: {
orStatement: {
statements: [
{
byteMatchStatement: {
searchString: 'admin.',
fieldToMatch: {
jsonBody: {
matchPattern: { includedPaths: ['/method'] },
matchScope: 'VALUE',
invalidFallbackBehavior: 'NO_MATCH', // Invalid JSON → no match (allow)
oversizeHandling: 'MATCH',
},
},
textTransformations: [{ priority: 0, type: 'LOWERCASE' }],
positionalConstraint: 'STARTS_WITH',
},
},
{
byteMatchStatement: {
searchString: '__proto__',
fieldToMatch: {
jsonBody: {
matchPattern: { includedPaths: ['/method'] },
matchScope: 'VALUE',
invalidFallbackBehavior: 'NO_MATCH',
oversizeHandling: 'MATCH',
},
},
textTransformations: [{ priority: 0, type: 'LOWERCASE' }],
positionalConstraint: 'CONTAINS',
},
},
{
byteMatchStatement: {
searchString: 'system.',
fieldToMatch: {
jsonBody: {
matchPattern: { includedPaths: ['/method'] },
matchScope: 'VALUE',
invalidFallbackBehavior: 'NO_MATCH',
oversizeHandling: 'MATCH',
},
},
textTransformations: [{ priority: 0, type: 'LOWERCASE' }],
positionalConstraint: 'STARTS_WITH',
},
},
],
},
},
action: { block: {} },
visibilityConfig: { /* ... */ },
},
The invalidFallbackBehavior: 'NO_MATCH' setting means that requests with non-JSON bodies (health checks that send empty bodies, for example) do not match this rule — they pass through normally. Only valid JSON bodies with a /method field containing the dangerous prefixes trigger the block. This avoids the common false-positive pattern where a generic body-inspection rule fires on non-JSON traffic.
Consolidated failure modes reference
| Symptom | Root cause | Diagnosis | Fix |
|---|---|---|---|
| All MCP API calls return 405 or CAPTCHA page | Bot Control scope-down missing or incorrect — /mcp* path included in Bot Control inspection |
CloudWatch WAF logs: terminatingRuleId: BotControlBrowserOnly on /mcp requests |
Add NOT ByteMatch scope-down for /mcp* and /api/* on the Bot Control rule |
| Intermittent 403 on tool calls with large document context | SizeRestrictions_BODY not excluded from CommonRuleSet — body over 8KB blocked |
WAF logs: terminatingRuleId: AWS-AWSManagedRulesCommonRuleSet, terminatingRuleMatchDetails: SizeRestrictions_BODY |
Add SizeRestrictions_BODY to excludedRules in CommonRuleSet config; add custom 512KB rule |
| 403 on tool calls from HTML-sanitization tools | CrossSiteScripting_BODY not excluded — HTML content in tool arguments flagged as XSS |
WAF logs: terminatingRuleMatchDetails: CrossSiteScripting_BODY |
Add CrossSiteScripting_BODY to excludedRules |
| 403 on tool calls that pass URL arguments | GenericRFI_BODY not excluded — URL strings in JSON params flagged as Remote File Inclusion |
WAF logs: terminatingRuleMatchDetails: GenericRFI_BODY |
Add GenericRFI_BODY to excludedRules |
| Uptime monitor triggers false positives and alert noise | Uptime monitor IP not in trusted IP set — subject to rate limits | WAF logs: terminatingRuleId: GlobalRateLimitMCPPost on monitor IP |
Add monitor IP range to trusted IP set at priority 0 |
| SSE clients get 429 after network blip | GET rate limit too strict — POST and GET rate-limited by same rule | WAF logs: rate limit firing on GET /mcp requests from reconnecting clients |
Split into separate POST rate limit (1,000) and GET rate limit (10,000); scope POST rule to POST method only |
| Geo block affects developers using VPN | Hard geo block on country code doesn't account for VPN exit nodes in that country | Developer reports block from known country; WAF logs confirm geo block on their IP's country | Replace hard BLOCK with geo-amplified rate limit; or add VPN provider CIDR ranges to trusted IP set |
| Rate limit fires on CloudFront setup but not ALB direct | aggregateKeyType: IP used on CloudFront — counting CloudFront edge IPs not client IPs |
Same IP (a CloudFront edge node) appears in all blocked requests; rate limit fires on low traffic | Switch to aggregateKeyType: FORWARDED_IP with X-Forwarded-For header and fallbackBehavior: MATCH |
| Label-chain block fires on authenticated users from geo-flagged region | Label-match rule does not check authentication status — emits high-risk-geo label on all requests from that region | WAF logs: terminatingRuleId: LabelChainHighRiskUnauthBlock on authenticated request |
Check EmitUnauthLabel rule logic — ensure NOT ByteMatch on Authorization header is correct; verify Bearer prefix check |
| Custom 429 JSON body not returned — client sees default HTML | customResponseBodyKey not registered in WebACL's customResponseBodies block |
Rate limit fires but response body is <html> not JSON-RPC error |
Add the key to the top-level customResponseBodies map on the WebACL; keys must be defined at WebACL level not rule level |
| Method injection rule fires on health check empty body | invalidFallbackBehavior set to MATCH — non-JSON bodies treated as matching |
WAF logs: method injection rule firing on GET /healthz or POST with empty body |
Set invalidFallbackBehavior: 'NO_MATCH' on all JSON body inspection rules |
Rollout sequence: always COUNT before BLOCK
Never enable a WAF rule in BLOCK mode on a production MCP endpoint without a COUNT-mode baselining period. The standard rollout sequence:
- Day 1–3: Deploy the entire WebACL with all managed rule groups in
overrideAction: { count: {} }and all custom rules inaction: { count: {} }. The trusted IP allow-list and OFAC geo block can go live immediately — these are low false-positive risk. - Day 4: Query CloudWatch Logs Insights for each rule's match count. Specifically check: how often does
SizeRestrictions_BODYmatch? If it matches frequently at low body sizes, you may have a different rule misconfiguration. DoesCrossSiteScripting_BODYmatch? How often? Are there matches you don't expect? - Day 5–7: Review the match pattern for Bot Control. Check what fraction of
/mcprequests are classified as bots. If it's high (over 5%), your AI agent clients may be sending unusual user-agents — add them to the allow list at priority 14 before enabling Bot Control blocking. - Day 8: Switch rate-based rules to BLOCK. Monitor for 24 hours. Rate limits are the highest-risk rules for false positives on SSE reconnects.
- Day 10: Switch managed rule groups to
overrideAction: { none: {} }(enforcing mode). Monitor for 48 hours. Watch for 403s on legitimate tool calls that indicate a missing exclusion. - Day 14: Enable Bot Control BLOCK mode on browser paths. By this point you know the exclusions are correct and the AI agent allow-list covers your clients.
The cost of a 14-day rollout is low: COUNT mode still logs everything, and you can query the logs to verify before each enablement. The cost of a wrong BLOCK rule on a production MCP endpoint is immediate user impact — every legitimate tool call to that endpoint starts returning 403.
Connecting WAF to MCP uptime monitoring
WAF blocks show up in uptime monitors as HTTP errors, but the error code alone doesn't tell you whether the problem is in your application or your WAF. If AliveMCP's health check pinger gets a 403, it could be a WAF geo block, a WAF rate limit, a WAF managed rule false positive, or an application-level authorization error. They all look the same from the outside.
The fix is two-sided. First, add AliveMCP's pinger IPs to your trusted IP set at priority 0 — this ensures uptime checks always succeed regardless of WAF state, which gives you a clean signal for application health separate from WAF health. Second, add a custom response header to all WAF BLOCK responses (X-Blocked-By: aws-waf) so that monitors that do get blocked can distinguish WAF blocks from application errors.
CloudWatch WAF metrics provide a secondary health signal: track BlockedRequests by rule name. A sudden spike in GlobalRateLimitMCPPost blocks might indicate an attack; a sudden spike in CommonRuleSetMCPTuned blocks might indicate a missing exclusion that just started firing on a new traffic pattern. Setting CloudWatch alarms on unexpected spikes in any managed rule group block count gives you operational visibility without relying on user-reported 403 errors.
Summary: three patterns, five rules
AWS WAF for MCP servers reduces to three structural patterns that address the ways the standard web-application WAF model doesn't fit the API context:
- The AI-bot exemption pattern: scope Bot Control to browser paths only using a NOT ByteMatch scope-down statement; add an explicit ALLOW rule for known AI agent user-agents before Bot Control evaluates; use COUNT-mode Bot Control on API paths for logging without blocking.
- The CommonRuleSet tuning pattern: exclude
SizeRestrictions_BODY,CrossSiteScripting_BODY, andGenericRFI_BODYbefore attaching CommonRuleSet to any MCP endpoint; replace the size rule with a custom 512KBSizeConstraintStatement; use JSON-RPC error format in custom response bodies. - The defense-in-depth stack pattern: run cheap rules first (IP reputation, geo, rate limits before managed rule groups); use COUNT-only label-emitting rules to build compound blocking conditions without complex nested statements; always COUNT before BLOCK during rollout.
These three patterns compose. The trusted IP allow-list at priority 0 applies to all subsequent rules. The label-chain block at priority 25 uses labels emitted by rules at priorities 6 and 7. The CommonRuleSet exclusions are independent of the Bot Control scope-down. You can add, tune, or remove any individual rule without disrupting the others — which is exactly the property you want in a security layer that you will be iterating on as your traffic patterns evolve.
AliveMCP monitors the health of MCP endpoints from the outside — including whether they return 403 WAF blocks when they shouldn't. If you want to know whether your WAF configuration is blocking legitimate MCP clients, the public dashboard at alivemcp.com shows each endpoint's current status and recent response code history. If your server's status shows 403 when you expect 200, it's a good first diagnostic step to check whether your trusted IP set includes the monitor's IP range.