Guide · AWS Network Firewall · Egress Security

AWS Network Firewall for MCP Servers — Suricata Rules, Domain Allowlisting, and Centralized Egress Inspection

MCP servers that call external APIs — LLM providers, third-party data services, webhooks — need egress filtering to prevent unauthorized outbound connections from a compromised container. AWS Network Firewall (NFW) is a managed stateful/stateless packet inspection service deployed inside a VPC that can block outbound traffic from MCP servers to everything except an explicit allowlist of domains and ports. Unlike AWS WAF (which filters HTTP requests at the load balancer layer, protecting inbound traffic), Network Firewall operates at the network layer and filters what your servers send out. The key architectural tradeoff: NFW is deployed in a dedicated firewall subnet and requires asymmetric route table configuration — private subnets route default traffic to NFW, NFW routes to the internet gateway, and the IGW edge route table routes return traffic back through NFW. Getting the routing right is the hardest part; the Suricata rule syntax for allowlisting specific domains is straightforward.

TL;DR

Create an NFW in a dedicated firewall subnet. Use a domain list rule group to allowlist specific hostnames (.anthropic.com, .amazonaws.com, api.github.com) — this is the simplest way to control MCP server egress without writing Suricata rules. Set the firewall policy default action to DROP for all unmatched stateful traffic. Fix the three route tables: private subnet RT (0.0.0.0/0 → NFW endpoint), public/firewall subnet RT (0.0.0.0/0 → IGW), IGW edge RT (private subnet CIDR → NFW endpoint). Then monitor alert logs before switching from ALERT to DROP for new domains.

Network Firewall architecture and routing

NFW endpoints are deployed in dedicated "firewall subnets" — one per AZ. These subnets should contain only the NFW endpoint, no EC2 instances. The routing model requires three route tables in a specific configuration to ensure both outbound and return traffic pass through the firewall (symmetric routing is required for stateful inspection).

# 1. Create the firewall subnets (one per AZ, dedicated to NFW)
# These are separate from your private (MCP server) and public subnets

# 2. Create the firewall policy
POLICY_ARN=$(aws network-firewall create-firewall-policy \
  --firewall-policy-name MCPServerEgressPolicy \
  --firewall-policy '{
    "StatelessDefaultActions": ["aws:forward_to_sfe"],
    "StatelessFragmentDefaultActions": ["aws:drop"],
    "StatefulDefaultActions": ["aws:drop_strict"],
    "StatefulEngineOptions": {"RuleOrder": "STRICT_ORDER"},
    "StatefulRuleGroupReferences": [],
    "StatelessRuleGroupReferences": []
  }' \
  --query 'FirewallPolicyResponse.FirewallPolicyArn' --output text)

# 3. Create the Network Firewall
FW_ARN=$(aws network-firewall create-firewall \
  --firewall-name MCPServerEgressFirewall \
  --firewall-policy-arn $POLICY_ARN \
  --vpc-id vpc-0mcp-production \
  --subnet-mappings \
    SubnetId=subnet-0firewall-a \
    SubnetId=subnet-0firewall-b \
  --query 'Firewall.FirewallArn' --output text)

# 4. Get the endpoint IDs (one per AZ) for route table configuration
aws network-firewall describe-firewall \
  --firewall-arn $FW_ARN \
  --query 'FirewallStatus.SyncStates'
# Returns per-AZ endpoint IDs like: vpce-0abc123 (Availability Zone: us-east-1a)

# 5. Private subnet route table: default route → NFW endpoint (per AZ)
aws ec2 create-route \
  --route-table-id rtb-0private-a \     # Private subnet A's route table
  --destination-cidr-block 0.0.0.0/0 \
  --vpc-endpoint-id vpce-0nfw-a          # NFW endpoint in same AZ as private subnet

# 6. Firewall subnet route table: default route → IGW
aws ec2 create-route \
  --route-table-id rtb-0firewall \
  --destination-cidr-block 0.0.0.0/0 \
  --gateway-id igw-0production

# 7. IGW edge route table: return traffic → NFW endpoint
aws ec2 create-route \
  --route-table-id rtb-0igw-edge \      # Attached to the IGW
  --destination-cidr-block 10.10.1.0/24 \  # Private subnet A's CIDR
  --vpc-endpoint-id vpce-0nfw-a

# Associate edge route table with the IGW
aws ec2 associate-route-table \
  --route-table-id rtb-0igw-edge \
  --gateway-id igw-0production

Using the wrong AZ for the NFW endpoint breaks stateful inspection. Private subnet A must route through NFW endpoint A (same AZ), not NFW endpoint B. When a packet leaves private subnet A through NFW endpoint B, the return packet comes back through the IGW edge route to NFW endpoint A (same AZ as private subnet A), creating a cross-AZ flow that breaks the stateful connection tracking — the firewall sees the return packet but not the corresponding SYN. Always match: private subnet AZ → NFW endpoint AZ → IGW edge route table entry → same NFW endpoint AZ.

Domain list rule groups — simplest egress allowlisting

Domain list rule groups are NFW's highest-level abstraction for HTTP/HTTPS egress control. You specify a list of domain names with a match action (ALLOWLIST or DENYLIST). NFW inspects the TLS SNI field (for HTTPS) or the HTTP Host header (for HTTP) and either passes or drops the connection. No Suricata syntax required — just domain strings.

# Create a domain list rule group allowing MCP server's external API dependencies
RG_ARN=$(aws network-firewall create-rule-group \
  --rule-group-name MCPAllowedDomains \
  --type STATEFUL \
  --capacity 100 \
  --rules-source '{
    "RulesSourceList": {
      "Targets": [
        ".anthropic.com",
        ".amazonaws.com",
        "api.openai.com",
        ".github.com",
        ".githubusercontent.com",
        "registry.npmjs.org",
        "pypi.org",
        ".pypi.org"
      ],
      "TargetTypes": ["TLS_SNI", "HTTP_HOST"],
      "GeneratedRulesType": "ALLOWLIST"
    }
  }' \
  --query 'RuleGroupResponse.RuleGroupArn' --output text)

# Add the rule group to the firewall policy
aws network-firewall update-firewall-policy \
  --firewall-policy-arn $POLICY_ARN \
  --firewall-policy '{
    "StatelessDefaultActions": ["aws:forward_to_sfe"],
    "StatelessFragmentDefaultActions": ["aws:drop"],
    "StatefulDefaultActions": ["aws:drop_strict"],
    "StatefulEngineOptions": {"RuleOrder": "STRICT_ORDER"},
    "StatefulRuleGroupReferences": [
      {"ResourceArn": "'$RG_ARN'", "Priority": 100}
    ]
  }'

# Update domain list to add a new allowed domain
aws network-firewall update-rule-group \
  --rule-group-arn $RG_ARN \
  --rules-source '{
    "RulesSourceList": {
      "Targets": [".anthropic.com", ".amazonaws.com", "api.stripe.com"],
      "TargetTypes": ["TLS_SNI", "HTTP_HOST"],
      "GeneratedRulesType": "ALLOWLIST"
    }
  }'

Domain list rules use the TLS SNI field, which is in the ClientHello and is not encrypted — no TLS inspection (man-in-the-middle) is required. This means the firewall cannot see the full URL path, only the hostname. It also means SNI-less TLS connections (rare but possible with old clients) are dropped. For non-TLS traffic, the HTTP Host header is used. Domain list rules support wildcards for subdomains: .amazonaws.com matches s3.us-east-1.amazonaws.com, lambda.us-east-1.amazonaws.com, etc. Without the leading dot, only the exact domain matches.

Suricata stateful rules — fine-grained protocol control

For control beyond domain matching — filtering by specific port ranges, protocol attributes, or content patterns — use Suricata-compatible rule syntax in a STATEFUL rule group. NFW supports Suricata 6.x rule syntax with the pass, drop, alert, and reject actions. Rules are evaluated in priority order (STRICT_ORDER mode recommended).

# Create a Suricata stateful rule group for fine-grained MCP server egress control
aws network-firewall create-rule-group \
  --rule-group-name MCPServerSuricataRules \
  --type STATEFUL \
  --capacity 200 \
  --rules '
# Allow HTTPS to Anthropic API (explicit pass before drop-all)
pass tls $HOME_NET any -> $EXTERNAL_NET 443 (tls.sni; content:"anthropic.com"; endswith; nocase; sid:1000001; rev:1;)

# Allow HTTPS to AWS services (by domain)
pass tls $HOME_NET any -> $EXTERNAL_NET 443 (tls.sni; content:".amazonaws.com"; endswith; nocase; sid:1000002; rev:1;)

# Alert on any non-HTTPS outbound before dropping (useful for baselining)
alert tcp $HOME_NET any -> $EXTERNAL_NET !443 (msg:"Non-HTTPS egress from MCP server"; flow:to_server; sid:2000001; rev:1;)

# Drop all other outbound TCP (must come AFTER pass rules in STRICT_ORDER)
drop tcp $HOME_NET any -> $EXTERNAL_NET any (msg:"Unauthorized egress blocked"; flow:to_server; sid:9000001; rev:1;)

# Drop all outbound UDP (DNS is handled by Route 53 resolver inside VPC)
drop udp $HOME_NET any -> $EXTERNAL_NET any (msg:"Outbound UDP blocked"; sid:9000002; rev:1;)
'

# STRICT_ORDER means rules are evaluated in the order they appear in the rule group
# pass rule at sid:1000001 fires first for Anthropic HTTPS
# drop rule at sid:9000001 fires last for everything else

In STRICT_ORDER mode, the order of rules within a rule group matters — a pass rule that matches stops evaluation for that connection without checking subsequent drop rules. In DEFAULT_ORDER mode (the older behavior), actions have built-in precedence (pass beats alert beats drop), which can cause unexpected behavior when the same traffic matches multiple rules of different types. Use STRICT_ORDER for new deployments to make rule evaluation predictable. The endswith and nocase Suricata options ensure the SNI match is a proper suffix check, not a substring match — content:"anthropic.com" without endswith would match evil-anthropic.com.

Stateless rule groups — high-throughput fast-path

Stateless rules are evaluated first, before stateful rules, and run at line rate without tracking connection state. Use them to fast-pass high-volume, predictable traffic (established TCP return packets) so the stateful engine doesn't process every packet in a long-lived HTTP/2 connection. Stateless rules use 5-tuple matching (source IP, source port, destination IP, destination port, protocol).

# Stateless rule group to fast-pass established TCP return traffic
aws network-firewall create-rule-group \
  --rule-group-name MCPStatelessPassthrough \
  --type STATELESS \
  --capacity 100 \
  --rules-source '{
    "StatelessRulesAndCustomActions": {
      "StatelessRules": [
        {
          "Priority": 10,
          "RuleDefinition": {
            "MatchAttributes": {
              "Sources": [{"AddressDefinition": "0.0.0.0/0"}],
              "Destinations": [{"AddressDefinition": "10.10.0.0/16"}],
              "Protocols": [6],
              "DestinationPorts": [{"FromPort": 1024, "ToPort": 65535}],
              "TCPFlags": [{"Flags": ["ACK"], "Masks": ["ACK", "SYN"]}]
            },
            "Actions": ["aws:pass"]
          }
        },
        {
          "Priority": 20,
          "RuleDefinition": {
            "MatchAttributes": {
              "Protocols": [6],
              "Sources": [{"AddressDefinition": "10.10.0.0/16"}],
              "Destinations": [{"AddressDefinition": "0.0.0.0/0"}],
              "SourcePorts": [{"FromPort": 443, "ToPort": 443}]
            },
            "Actions": ["aws:pass"]
          }
        }
      ]
    }
  }'

The TCPFlags match with ACK flag set and SYN flag not set identifies established TCP connections (non-SYN packets). These packets are safe to fast-pass because the stateful engine already approved the initial SYN. Fast-passing return traffic (from external destinations to private subnet IPs) reduces the load on the stateful engine and reduces per-packet latency for ongoing API calls. Do not fast-pass SYN packets — those need stateful evaluation to enforce domain allow/deny rules. The stateless engine does not do stateful tracking; passing a SYN here would bypass domain list checks entirely.

Logging and alert-before-drop workflow

Before enabling drop rules on a new MCP server deployment, run NFW in alert-only mode: replace drop with alert in all rules, configure logging to CloudWatch Logs or S3, and monitor for 24–48 hours to discover which domains the server actually connects to. Then add missing domains to the allowlist before switching to drop mode.

# Configure NFW alert logging to CloudWatch Logs
aws network-firewall update-logging-configuration \
  --firewall-arn $FW_ARN \
  --logging-configuration '{
    "LogDestinationConfigs": [
      {
        "LogType": "ALERT",
        "LogDestinationType": "CloudWatchLogs",
        "LogDestination": {
          "logGroup": "/aws/network-firewall/mcp-server/alerts"
        }
      },
      {
        "LogType": "FLOW",
        "LogDestinationType": "CloudWatchLogs",
        "LogDestination": {
          "logGroup": "/aws/network-firewall/mcp-server/flow"
        }
      }
    ]
  }'

# Query recent alert logs to find domains that would be blocked
aws logs filter-log-events \
  --log-group-name /aws/network-firewall/mcp-server/alerts \
  --start-time $(($(date +%s) - 86400))000 \
  --filter-pattern '{ $.event.alert.action = "blocked" }' \
  --query 'events[*].message' \
  | jq -r '.[].event.tls.sni // .[].event.http.hostname' \
  | sort -u

# Check flow logs to see which external IPs the MCP server connects to
aws logs filter-log-events \
  --log-group-name /aws/network-firewall/mcp-server/flow \
  --filter-pattern '{ $.event.src_ip = "10.10.*" }' \
  --query 'events[*].message' \
  | jq -r '.[].event.dest_ip' \
  | sort | uniq -c | sort -rn | head -20

ALERT log entries include the rule SID that triggered, the source and destination IP, and for TLS traffic, the SNI field. This makes it straightforward to identify which domains to add to the allowlist before switching rules from alert to drop. Flow logs show all passing traffic (not just rule-matched traffic), enabling you to see the full egress profile including traffic that is already explicitly allowed. Store flow logs in S3 rather than CloudWatch for high-volume deployments — CloudWatch ingestion costs ($0.50/GB) add up quickly for active MCP servers.

Failure modes reference

FailureSymptomFix
Asymmetric routing — return packets bypass firewallOutbound connections fail; TCP sessions don't establish despite correct pass rulesIGW edge route table is missing routes for private subnet CIDRs pointing to NFW endpoint; stateful engine sees SYN allowed but never sees SYN-ACK response — connection tracking table never reaches ESTABLISHED; add IGW edge RT entries for all private subnets
Wrong AZ NFW endpoint in route tableIntermittent connection failures that correlate with specific AZsPrivate subnet A must route to NFW endpoint A (same AZ); cross-AZ NFW routing breaks stateful tracking; use aws network-firewall describe-firewall --query FirewallStatus.SyncStates to find per-AZ endpoint IDs and match them to route tables by AZ
Domain list rule not blocking HTTPS trafficUnallowed HTTPS domains are not being dropped despite DENYLIST or missing from ALLOWLISTEnsure firewall policy StatefulDefaultActions includes "aws:drop_strict"; without it, unmatched traffic passes even if no rule explicitly allows it; also verify the domain list rule group is referenced in the policy StatefulRuleGroupReferences
SNI-less connections bypass domain filteringSome HTTPS traffic passes despite domain not being in allowlistHTTPS connections without SNI (older TLS 1.0/1.1 clients) cannot be domain-matched; add a Suricata rule to drop TLS without SNI: drop tls any any -> any 443 (tls.sni; noalert; content:""; isnotset; sid:8000001; rev:1;)
NFW increases latency for all outbound trafficAPI call latency increases by 1–3ms across all egressNFW adds ~0.5–1ms per packet for stateful inspection; use stateless pass rules for established TCP return traffic to reduce stateful engine load; consider whether NFW is necessary or if VPC endpoint policies achieve the same protection goal at lower latency cost