[Verification] Using Brave Search from VPC: Testing whether web search and AI answers work by opening only api.search.brave.com in Network Firewall

[Verification] Using Brave Search from VPC: Testing whether web search and AI answers work by opening only api.search.brave.com in Network Firewall

We verified through actual hands-on testing how to securely allow outbound communication using AWS Network Firewall when integrating web search functionality using the Brave Search API into an AI agent running on Fargate/AgentCore. We confirmed that all functionality from web search to AI response (summarization) works by simply allowing a single domain: `api.search.brave.com`.
2026.06.04

This page has been translated by machine translation. View original

Hello, I'm Keema.

I often hear requests to incorporate web search functionality into AI agents running on Fargate or AgentCore.
However, in corporate network requirements, it's common not to freely allow communication from VPCs to the external internet, and instead use firewalls like AWS Network Firewall to open holes only for "permitted destinations."
This makes me wonder: how many holes need to be opened to add web search?

Looking at the Brave Search API documentation, endpoints for Web Search, Summarizer (AI summary), and others are all consolidated under subpaths of api.search.brave.com.
For example, Web Search requests are sent to api.search.brave.com as follows.

In other words, if you allow just the single domain api.search.brave.com in Network Firewall, both the AI agent's web search and AI responses should go through.
This article verifies whether this assumption actually holds.
I set up a minimal configuration with a single VPC and Network Firewall, allowed only the single domain api.search.brave.com, and confirmed whether curl, Python programs, and AI responses (summaries) actually work.
This summarizes the results of actual deployment and verification in the Tokyo region (ap-northeast-1) as of June 2026.
I hope this serves as a reference for how to open firewall holes when adding search functionality to AI agents.

Target audience: Those considering opening firewall holes to call external APIs (such as web search APIs) from a VPC, and those who want to try AWS Network Firewall's domain filter (SNI filter).

1. What This Hands-On Will Build

We'll deploy the following foundation in one shot with CDK, then experience "opening domain holes" with the CLI.

  • Single VPC, single AZ. Three-tier subnets (workload / public(NAT) / firewall)
  • AWS Network Firewall (stateful, initially blocking all egress)
  • Verification EC2 (Amazon Linux 2023). Connect via SSM Session Manager
  • VPC interface endpoints for SSM and Secrets Manager
  • Secrets Manager secret for the Brave API key

Domain permission (api.search.brave.com) is intentionally not included in CDK.
Opening it with the CLI after deployment is the main focus of this hands-on.

What we most want to confirm is: with only the single domain api.search.brave.com permitted, can the full range of Brave Search API features—from web search to AI responses (summaries)—actually work?

2. Architecture and Traffic Flow

2.1 Overall Architecture

Here is an overview of the architecture verified this time.

Architecture diagram showing egress from VPC to Brave Search API. A single VPC has three-tier subnets, with Network Firewall performing domain inspection
Single VPC architecture. Security groups restrict ports to TCP 443, and Network Firewall handles destination domain inspection

Traffic originates from the EC2 in the workload subnet, passes through NAT Gateway → Network Firewall → Internet Gateway in that order, and reaches api.search.brave.com.
The security group attached to the EC2 in the workload subnet only restricts outbound to port 443, leaving the destination IP as 0.0.0.0/0.
Network Firewall inspects the SNI to determine which domains are reachable.
The API key is retrieved from Secrets Manager via a VPC endpoint without going out to the internet.

2.2 Return Traffic Also Passes Through Network Firewall

Not only outbound traffic (EC2 → internet), but return traffic (responses from the internet) must also always pass through Network Firewall.
The domain (SNI) filter used here is a "stateful" inspection that needs to see both outbound and return traffic of a single connection along the same path (Network Firewall also has stateless inspection, but domain judgment is performed on the stateful side).
If the return traffic doesn't pass through Network Firewall, the inspection becomes asymmetric and communication breaks.

Therefore, an "edge route table" is associated with the Internet Gateway, routing the return traffic (destination = public subnet where NAT resides) via a detour through Network Firewall.
In the architecture diagram above, the red dashed line returning from Internet Gateway to Network Firewall represents this path.

In production, a common architecture aggregates multiple application VPCs via Transit Gateway and uses a centrally placed Network Firewall in an egress VPC to inspect all outbound traffic.
Since the purpose of this verification is to confirm "whether allowing just 1 domain is sufficient," we keep it to a single VPC and single AZ that can be reliably deployed in a single CDK file.

3. Prerequisites

Required Content
AWS account Verification account
Region This article uses ap-northeast-1 (Tokyo)
Node.js / AWS CDK Node.js 18 or higher, AWS CDK v2
AWS CLI v2 Used for Network Firewall CLI operations and SSM connections
Session Manager plugin Required for aws ssm start-session
Brave Search API key Subscribe via AWS Marketplace and obtain key

The Brave Search API is available on AWS Marketplace.
Subscribe to "Brave Search API" on AWS Marketplace and issue a key from the Brave dashboard (Marketplace page: AWS Marketplace: Brave Search API | AWS).

4. Deploy with CDK

4.1 What app.ts Creates

CDK is consolidated into a single stack definition file (app.ts).
Reading app.ts from top to bottom, it creates the following:

  1. VPC and Internet Gateway: Creates a VPC with 10.0.0.0/16 and attaches an IGW.
  2. Three-tier subnets: workload (for EC2 / private) / public (for NAT) / firewall (for Network Firewall endpoint). All placed in a single AZ.
  3. NAT Gateway: Placed in the public subnet. EC2 in the private subnet has no public IP, and outbound traffic goes through NAT.
  4. Network Firewall policy: Stateful with STRICT_ORDER, default action set to aws:drop_established. No rule groups attached = all egress blocked as the initial state.
  5. Network Firewall itself: Creates the firewall in the firewall subnet.
  6. Four route tables: workload subnet → NAT, public subnet (NAT) → Network Firewall, firewall subnet → IGW, and an "IGW edge route" that detours return traffic from IGW to Network Firewall. This ensures all traffic passes through Network Firewall.
  7. Secrets Manager secret: Creates a placeholder for the Brave API key (value is entered later with the CLI).
  8. VPC interface endpoints: ssm / ssmmessages (for SSM connection) and secretsmanager (for key retrieval). By making these endpoints, SSM connection and key retrieval remain within the VPC even when Network Firewall drops everything except Brave.
  9. Verification EC2: Amazon Linux 2023. SG outbound is limited to TCP443 to 0.0.0.0/0 and DNS (53) only. Destination IP is not restricted; domain control is left to Network Firewall.

The full text of app.ts and configuration files is included in the collapsible sections below.

app.ts (click to expand)
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import {
  aws_ec2 as ec2,
  aws_iam as iam,
  aws_logs as logs,
  aws_networkfirewall as netfw,
  aws_secretsmanager as secretsmanager,
} from 'aws-cdk-lib';

// Parameters for this material (change as needed)
const VPC_CIDR = '10.0.0.0/16';
const WORKLOAD_CIDR = '10.0.1.0/24'; // Verification EC2 (private)
const PUBLIC_CIDR = '10.0.0.0/24'; // NAT Gateway (public)
const FIREWALL_CIDR = '10.0.2.0/24'; // Network Firewall endpoint
const SECRET_NAME = 'brave-search/api-key';
const FIREWALL_NAME = 'brave-egress-fw';

class BraveNfwEgressStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const az = cdk.Fn.select(0, cdk.Fn.getAzs());

    // 1. VPC and Internet Gateway
    const vpc = new ec2.CfnVPC(this, 'Vpc', {
      cidrBlock: VPC_CIDR,
      enableDnsSupport: true,
      enableDnsHostnames: true,
      tags: [{ key: 'Name', value: 'brave-nfw-egress-vpc' }],
    });
    const igw = new ec2.CfnInternetGateway(this, 'Igw', {
      tags: [{ key: 'Name', value: 'brave-nfw-igw' }],
    });
    const igwAttach = new ec2.CfnVPCGatewayAttachment(this, 'IgwAttach', {
      vpcId: vpc.ref,
      internetGatewayId: igw.ref,
    });

    // 2. Three-tier subnets (all in a single AZ)
    const workloadSubnet = new ec2.CfnSubnet(this, 'WorkloadSubnet', {
      vpcId: vpc.ref, cidrBlock: WORKLOAD_CIDR, availabilityZone: az,
      mapPublicIpOnLaunch: false, tags: [{ key: 'Name', value: 'workload-private' }],
    });
    const publicSubnet = new ec2.CfnSubnet(this, 'PublicSubnet', {
      vpcId: vpc.ref, cidrBlock: PUBLIC_CIDR, availabilityZone: az,
      mapPublicIpOnLaunch: false, tags: [{ key: 'Name', value: 'nat-public' }],
    });
    const firewallSubnet = new ec2.CfnSubnet(this, 'FirewallSubnet', {
      vpcId: vpc.ref, cidrBlock: FIREWALL_CIDR, availabilityZone: az,
      mapPublicIpOnLaunch: false, tags: [{ key: 'Name', value: 'firewall' }],
    });

    // 3. NAT Gateway (placed in public subnet)
    const natEip = new ec2.CfnEIP(this, 'NatEip', { domain: 'vpc' });
    const natgw = new ec2.CfnNatGateway(this, 'NatGw', {
      subnetId: publicSubnet.ref, allocationId: natEip.attrAllocationId,
      tags: [{ key: 'Name', value: 'brave-nfw-nat' }],
    });
    natgw.addDependency(igwAttach);

    // 4. Network Firewall (policy + firewall itself). Initially no rule groups = all egress blocked.
    //    Using drop_established instead of drop_strict (to allow connection establishment for reading SNI).
    const policy = new netfw.CfnFirewallPolicy(this, 'FwPolicy', {
      firewallPolicyName: 'brave-egress-policy',
      firewallPolicy: {
        statelessDefaultActions: ['aws:forward_to_sfe'],
        statelessFragmentDefaultActions: ['aws:forward_to_sfe'],
        statefulEngineOptions: { ruleOrder: 'STRICT_ORDER' },
        statefulDefaultActions: ['aws:drop_established'],
      },
    });
    const firewall = new netfw.CfnFirewall(this, 'Firewall', {
      firewallName: FIREWALL_NAME,
      firewallPolicyArn: policy.attrFirewallPolicyArn,
      vpcId: vpc.ref,
      subnetMappings: [{ subnetId: firewallSubnet.ref }],
    });
    // attrEndpointIds is returned as an array in ["<az>:<vpce-id>"] format
    const fwEndpointId = cdk.Fn.select(1, cdk.Fn.split(':', cdk.Fn.select(0, firewall.attrEndpointIds)));

    // NFW flow logs
    const fwLogGroup = new logs.LogGroup(this, 'FwFlowLogs', {
      logGroupName: '/brave-nfw/flow',
      retention: logs.RetentionDays.ONE_WEEK,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });
    new netfw.CfnLoggingConfiguration(this, 'FwLogging', {
      firewallArn: firewall.attrFirewallArn,
      loggingConfiguration: {
        logDestinationConfigs: [
          { logType: 'FLOW', logDestinationType: 'CloudWatchLogs',
            logDestination: { logGroup: fwLogGroup.logGroupName } },
        ],
      },
    });

    // 5. Route tables and routes
    // 5-1. workload → NAT
    const rtWorkload = new ec2.CfnRouteTable(this, 'RtWorkload', { vpcId: vpc.ref, tags: [{ key: 'Name', value: 'rt-workload' }] });
    new ec2.CfnSubnetRouteTableAssociation(this, 'AssocWorkload', { subnetId: workloadSubnet.ref, routeTableId: rtWorkload.ref });
    new ec2.CfnRoute(this, 'WorkloadDefaultRoute', { routeTableId: rtWorkload.ref, destinationCidrBlock: '0.0.0.0/0', natGatewayId: natgw.ref });
    // 5-2. public(NAT) → NFW endpoint
    const rtPublic = new ec2.CfnRouteTable(this, 'RtPublic', { vpcId: vpc.ref, tags: [{ key: 'Name', value: 'rt-nat-public' }] });
    new ec2.CfnSubnetRouteTableAssociation(this, 'AssocPublic', { subnetId: publicSubnet.ref, routeTableId: rtPublic.ref });
    new ec2.CfnRoute(this, 'PublicDefaultRoute', { routeTableId: rtPublic.ref, destinationCidrBlock: '0.0.0.0/0', vpcEndpointId: fwEndpointId });
    // 5-3. firewall → IGW
    const rtFirewall = new ec2.CfnRouteTable(this, 'RtFirewall', { vpcId: vpc.ref, tags: [{ key: 'Name', value: 'rt-firewall' }] });
    new ec2.CfnSubnetRouteTableAssociation(this, 'AssocFirewall', { subnetId: firewallSubnet.ref, routeTableId: rtFirewall.ref });
    const fwDefaultRoute = new ec2.CfnRoute(this, 'FirewallDefaultRoute', { routeTableId: rtFirewall.ref, destinationCidrBlock: '0.0.0.0/0', gatewayId: igw.ref });
    fwDefaultRoute.addDependency(igwAttach);
    // 5-4. IGW edge route (detour return traffic to NFW)
    const rtIgwEdge = new ec2.CfnRouteTable(this, 'RtIgwEdge', { vpcId: vpc.ref, tags: [{ key: 'Name', value: 'rt-igw-edge' }] });
    const igwEdgeAssoc = new ec2.CfnGatewayRouteTableAssociation(this, 'AssocIgwEdge', { gatewayId: igw.ref, routeTableId: rtIgwEdge.ref });
    igwEdgeAssoc.addDependency(igwAttach);
    new ec2.CfnRoute(this, 'IgwEdgeReturnRoute', { routeTableId: rtIgwEdge.ref, destinationCidrBlock: PUBLIC_CIDR, vpcEndpointId: fwEndpointId });

    // 6. Secrets Manager (placeholder)
    const braveSecret = new secretsmanager.Secret(this, 'BraveApiKey', {
      secretName: SECRET_NAME,
      description: 'Brave Search API key. Set via: aws secretsmanager put-secret-value',
    });
    braveSecret.applyRemovalPolicy(cdk.RemovalPolicy.DESTROY);

    // 7. VPC endpoints (ssm / ssmmessages / secretsmanager)
    const endpointSg = new ec2.CfnSecurityGroup(this, 'EndpointSg', {
      vpcId: vpc.ref,
      groupDescription: 'SG for interface endpoints (allow 443 from VPC)',
      securityGroupIngress: [{ ipProtocol: 'tcp', fromPort: 443, toPort: 443, cidrIp: VPC_CIDR, description: 'HTTPS from within VPC' }],
      tags: [{ key: 'Name', value: 'endpoint-sg' }],
    });
    for (const svc of ['ssm', 'ssmmessages', 'secretsmanager']) {
      new ec2.CfnVPCEndpoint(this, `Endpoint-${svc}`, {
        vpcId: vpc.ref,
        serviceName: `com.amazonaws.${this.region}.${svc}`,
        vpcEndpointType: 'Interface',
        subnetIds: [workloadSubnet.ref],
        securityGroupIds: [endpointSg.ref],
        privateDnsEnabled: true,
      });
    }

    // 8. Verification EC2 (Amazon Linux 2023). SG descriptions in ASCII only.
    const instanceSg = new ec2.CfnSecurityGroup(this, 'InstanceSg', {
      vpcId: vpc.ref,
      groupDescription: 'Workload SG: egress 443 + DNS only (domain control is on NFW)',
      securityGroupEgress: [
        { ipProtocol: 'tcp', fromPort: 443, toPort: 443, cidrIp: '0.0.0.0/0', description: 'HTTPS out (dest IP not restricted; domain control on NFW)' },
        { ipProtocol: 'udp', fromPort: 53, toPort: 53, cidrIp: VPC_CIDR, description: 'DNS' },
        { ipProtocol: 'tcp', fromPort: 53, toPort: 53, cidrIp: VPC_CIDR, description: 'DNS over TCP' },
      ],
      tags: [{ key: 'Name', value: 'workload-sg' }],
    });
    const role = new iam.Role(this, 'InstanceRole', {
      assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
      managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore')],
    });
    braveSecret.grantRead(role);
    const instanceProfile = new iam.CfnInstanceProfile(this, 'InstanceProfile', { roles: [role.roleName] });
    const ami = ec2.MachineImage.latestAmazonLinux2023().getImage(this);
    const instance = new ec2.CfnInstance(this, 'WorkloadInstance', {
      imageId: ami.imageId,
      instanceType: 't3.micro',
      subnetId: workloadSubnet.ref,
      securityGroupIds: [instanceSg.ref],
      iamInstanceProfile: instanceProfile.ref,
      tags: [{ key: 'Name', value: 'brave-workload' }],
    });

    // 9. Outputs
    new cdk.CfnOutput(this, 'FirewallName', { value: FIREWALL_NAME });
    new cdk.CfnOutput(this, 'FirewallPolicyArn', { value: policy.attrFirewallPolicyArn });
    new cdk.CfnOutput(this, 'InstanceId', { value: instance.ref });
    new cdk.CfnOutput(this, 'SecretName', { value: SECRET_NAME });
    new cdk.CfnOutput(this, 'SsmStartSession', { value: `aws ssm start-session --target ${instance.ref}` });
  }
}

const app = new cdk.App();
new BraveNfwEgressStack(app, 'BraveNfwEgressStack', {
  env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
});
app.synth();
package.json / tsconfig.json / cdk.json (click to expand)

package.json

{
  "name": "brave-nfw-egress-cdk",
  "version": "1.0.0",
  "private": true,
  "scripts": { "synth": "cdk synth", "deploy": "cdk deploy", "destroy": "cdk destroy" },
  "devDependencies": {
    "@types/node": "^22.10.2",
    "aws-cdk": "^2.1000.0",
    "ts-node": "^10.9.2",
    "typescript": "~5.6.3"
  },
  "dependencies": { "aws-cdk-lib": "^2.170.0", "constructs": "^10.4.2" }
}

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "exclude": ["node_modules", "cdk.out"]
}

cdk.json

{
  "app": "npx ts-node --prefer-ts-exts app.ts"
}

4.2 Deployment Steps

cd cdk
npm install

# First time only: CDK bootstrap
npx cdk bootstrap --profile <YOUR_PROFILE>

# Deploy (takes 5-10 minutes including Network Firewall creation)
npx cdk deploy --profile <YOUR_PROFILE>

In the author's environment, deployment took about 6 minutes.
Upon completion, the Outputs will show the following values (used in later steps).

# Example output
BraveNfwEgressStack.FirewallName = brave-egress-fw
BraveNfwEgressStack.InstanceId = i-085a093e0b9a31177
BraveNfwEgressStack.SecretName = brave-search/api-key
BraveNfwEgressStack.SsmStartSession = aws ssm start-session --target i-085a093e0b9a31177

5. [Step 1] Confirm the All-Blocked State

First, log in to the EC2.
Connect via SSM using the command from the Outputs.

aws ssm start-session --target <InstanceId> --profile <YOUR_PROFILE> --region ap-northeast-1

From inside the EC2, try curl to Brave and some other domain.
Since no domain permissions have been added yet right after deployment, both should be blocked.

# Confirm that the domain name can be resolved to an IP address (name resolution works)
getent hosts api.search.brave.com

# Since nothing is permitted yet, connections to any site should time out (be blocked)
curl -sS -o /dev/null -w 'http_code=%{http_code} time=%{time_total}s\n' --max-time 12 https://api.search.brave.com/
curl -sS -o /dev/null -w 'http_code=%{http_code} time=%{time_total}s\n' --max-time 12 https://example.com/

The actual output was as follows.

# Example output
15.197.138.111  api.search.brave.com
3.33.153.106    api.search.brave.com

http_code=000 time=12.001807s   # Brave    → timeout
http_code=000 time=12.000979s   # example  → timeout
curl: (28) Connection timed out after 12001 milliseconds

While getent hosts successfully resolves the domain name to an IP address, HTTPS for both shows http_code=000 with a 12-second timeout, confirming that outbound communication is blocked.

6. [Step 2] Open a Hole to api.search.brave.com via CLI

This is the main part.
To add domain permissions in Network Firewall, you need to be aware of the following three-level hierarchy.

[Network Firewall Configuration Hierarchy]

 1. Rule Group        <-- [Create] List of "which sites to allow"
          ▼ (attach)
 2. Firewall Policy  <-- [Update] Bundles rule groups and passes them to the engine
          ▼ (auto-sync)
 3. Firewall         <-- [Apply] Configuration reaches the endpoint within the VPC

This time, we'll create a new "rule group" that permits only traffic to api.search.brave.com (TLS SNI), then associate it with the policy (brave-egress-policy) already created by CDK in Chapter 4, to actually open a hole in the VPC's communication.
This policy was created with CDK as STRICT_ORDER with default action aws:drop_established (no rule groups = all egress blocked). By adding a permit rule group to this, traffic to api.search.brave.com will finally be allowed through.
Run these operations from your local terminal (not inside the EC2).

First, create an allow list (ALLOWLIST) rule group definition.
Save the following content on your local terminal as a file named allow-brave.json (it will be loaded as file://allow-brave.json in the create-rule-group command later).

Note that STRICT_ORDER is one of the stateful rule evaluation methods. It "evaluates from top to bottom in the order you specify (by Priority, with smaller numbers evaluated first), and if no rule matches, applies the default action (in this case, drop = block)." This creates the behavior of "allow only domains on the permit list, block everything else." Since the CDK policy (brave-egress-policy) is created with STRICT_ORDER, the rule group side must also be aligned to STRICT_ORDER (they cannot be attached if the evaluation methods don't match).

{
  "RulesSource": {
    "RulesSourceList": {
      "Targets": ["api.search.brave.com"],
      "TargetTypes": ["TLS_SNI"],
      "GeneratedRulesType": "ALLOWLIST"
    }
  },
  "StatefulRuleOptions": {
    "RuleOrder": "STRICT_ORDER"
  }
}

The meaning of each item in this JSON is as follows:

  • Targets: List of destination domains to allow. Only api.search.brave.com is written here (= permit only this 1 domain)
  • TLS_SNI in TargetTypes: Specifies to look at the destination hostname (SNI) included in the TLS handshake of HTTPS communication and match it against the Targets above
  • ALLOWLIST in GeneratedRulesType: Allows only the domains listed here, blocking everything else (allow-list approach)

In other words, this rule group contains the "1-domain hole" that Network Firewall opens for api.search.brave.com.

6.1 Creating the Rule Group

Create the actual "rule group" entity from the allow-brave.json saved earlier.

RG_ARN=$(aws network-firewall create-rule-group \
  --rule-group-name allow-brave \
  --type STATEFUL \
  --capacity 100 \
  --rule-group file://allow-brave.json \
  --region ap-northeast-1 --profile <YOUR_PROFILE> \
  --query 'RuleGroupResponse.RuleGroupArn' --output text)

echo "$RG_ARN"
# Example output
arn:aws:network-firewall:ap-northeast-1:<ACCOUNT_ID>:stateful-rulegroup/allow-brave

6.2 Attaching to a Policy (Linking)

Next, we add the rule group we created to the policy created with CDK (brave-egress-policy).
Since Network Firewall only uses rules linked to a policy, the permission only becomes effective after this linking.
The current UpdateToken is required for the update. This is a passphrase indicating "the policy you currently have is the latest version," and by including it when sending, you can safely overwrite without conflicting with other operations. Let's obtain it in advance.

# Get the current policy token
TOKEN=$(aws network-firewall describe-firewall-policy \
  --firewall-policy-name brave-egress-policy \
  --region ap-northeast-1 --profile <YOUR_PROFILE> \
  --query 'UpdateToken' --output text)

# Update the policy by adding a reference to the rule group
aws network-firewall update-firewall-policy \
  --firewall-policy-name brave-egress-policy \
  --update-token "$TOKEN" \
  --region ap-northeast-1 --profile <YOUR_PROFILE> \
  --firewall-policy "{
    \"StatelessDefaultActions\": [\"aws:forward_to_sfe\"],
    \"StatelessFragmentDefaultActions\": [\"aws:forward_to_sfe\"],
    \"StatefulEngineOptions\": {\"RuleOrder\": \"STRICT_ORDER\"},
    \"StatefulDefaultActions\": [\"aws:drop_established\"],
    \"StatefulRuleGroupReferences\": [
      {\"ResourceArn\": \"$RG_ARN\", \"Priority\": 100}
    ]
  }"

The items in the JSON passed to --firewall-policy are as follows. The only new addition this time is the last StatefulRuleGroupReferences; the others are simply re-specifying the initial settings created by CDK.

  • StatelessDefaultActions / StatelessFragmentDefaultActions: Default behavior at the stateless stage. aws:forward_to_sfe means "forward to the stateful engine for inspection"
  • StatefulEngineOptions.RuleOrder: STRICT_ORDER (strict order as explained above)
  • StatefulDefaultActions: aws:drop_established (traffic that doesn't match any rule is blocked)
  • StatefulRuleGroupReferences: This is the new addition. Adds the allow-brave rule group created earlier ($RG_ARN) as a reference with Priority: 100

Since update-firewall-policy replaces the entire policy, all items that are not being changed (such as StatelessDefaultActions) must also be included without omission.

After the update, wait until the firewall configuration becomes IN_SYNC.

aws network-firewall describe-firewall --firewall-name brave-egress-fw \
  --region ap-northeast-1 --profile <YOUR_PROFILE> \
  --query 'FirewallStatus.ConfigurationSyncStateSummary' --output text

In my environment, it became IN_SYNC within a few tens of seconds.

7. [Step 3] Testing the Full Flow: Question → AI Answer (Summary)

When adding search functionality to an AI agent, what you want is not "a list of search result URLs" but "an answer to the question."
The Brave Search API has a Summarizer where Brave reads multiple pages and synthesizes an answer, and this also works entirely within the same api.search.brave.com.
Here, we verify that the full flow from "question → AI answer (summary)" is reachable through the opened hole.

API What it returns Use case
Web Search title, url, description (excerpt) Basic web search
Summarizer (summary=1/res/v1/summarizer/search) AI answer synthesized by Brave reading multiple pages + citations Answering questions, AI assistant

Since both use subpaths of api.search.brave.com, only one hole needs to be opened.

7.1 Inserting the API Key

CDK only creates a placeholder secret; the value has not been entered yet.
Insert the Brave API key.

aws secretsmanager put-secret-value \
  --secret-id brave-search/api-key \
  --secret-string '<YOUR_BRAVE_API_KEY>' \
  --region ap-northeast-1 --profile <YOUR_PROFILE>

7.2 Verifying the Question → AI Answer (Summary) Flow

What we really want to do through the opened hole is not to get a list of URLs, but to get "answers to questions."
Here, we try asking "What is AWS Network Firewall and what is it used for?" and verify that we can receive the answer via Brave's Summarizer (AI-generated summary answer).

The Summarizer uses this question to make 2 requests.

  1. Receive a summary key: Pass the question to the regular web search endpoint web/search with summary=1 appended. The summary=1 parameter instructs Brave to "create an AI summary answer based on these search results." Instead of returning the answer itself, a 'summary key' (an ID used to retrieve the answer later) is returned in summarizer.key in the response.
  2. Receive the AI answer: Pass that summary key to the key parameter of the summarizer/search endpoint, and you get back an AI answer synthesized by Brave reading multiple pages.

The flow is "question → summary key → AI answer."
First, retrieve the key from Secrets Manager, and use curl to verify that the first stage (sending the question with summary=1) can reach api.search.brave.com.

TOKEN=$(aws secretsmanager get-secret-value \
  --secret-id brave-search/api-key \
  --query SecretString --output text --region ap-northeast-1)

# Summarizer stage 1: Web search with summary=1 (destination is api.search.brave.com)
curl -s -o /dev/null -w 'http_code=%{http_code}\n' \
  -H "X-Subscription-Token: $TOKEN" \
  "https://api.search.brave.com/res/v1/web/search?q=What+is+AWS+Network+Firewall+and+what+is+it+used+for&summary=1&count=5"

# A different domain that is not permitted still times out
curl -sS -o /dev/null -w 'http_code=%{http_code} time=%{time_total}s\n' --max-time 12 https://example.com/

The comparison before and after opening the hole is as follows.

Destination Step 1 (before opening) Step 3 (after opening)
api.search.brave.com http_code=000 (timeout = blocked) http_code=200 (reached, success)
example.com http_code=000 (timeout = blocked) http_code=000 (12-second timeout = still blocked)

The security group has not been changed at all (egress remains TCP443 to 0.0.0.0/0).
Yet only Brave traffic passes while other traffic is blocked.
This confirms that domain control is being enforced on the Network Firewall side.

Next, here is the Python code that executes the two stages above (retrieving the summary key → retrieving the AI answer), assuming a real application running on Fargate / AgentCore.
The core consists of the following 2 requests.

# The 2 core requests (see the full brave_answer.py below for variable/function definitions)
# 1) Web search with summary=1, extract the summary key (summarizer.key) from the response
st1, d1 = get_json(f"{BASE}/web/search?q={question}&summary=1&count=5", key)
skey = (d1.get("summarizer") or {}).get("key")   # Summary key passed to the next request

# 2) Pass the summary key to key parameter to retrieve the synthesized AI answer
st2, d2 = get_json(f"{BASE}/summarizer/search?key={skey}&inline_references=true", key)

The key is not stored in environment variables or plaintext; it is retrieved at runtime from Secrets Manager.
The full file you can copy and run directly is as follows. It runs with python3 brave_answer.py "<question>".

Full brave_answer.py (click to expand)
#!/usr/bin/env python3
"""A sample that uses Brave Search API's "AI Summary (Summarizer / AI Answers)" from a VPC
to get answers (summary + citations) to questions.

Flow (all completed within the same domain api.search.brave.com):
  1) /res/v1/web/search?q=...&summary=1   → Obtain summarizer.key from the response
  2) /res/v1/summarizer/search?key=...     → Retrieve the summary (answer to the question)
Brave's server handles summarization and content extraction, so the outbound hole remains a single domain.
"""
import json
import sys
import urllib.parse
import urllib.request
import urllib.error

REGION = "ap-northeast-1"
SECRET_ID = "brave-search/api-key"
BASE = "https://api.search.brave.com/res/v1"

def get_api_key() -> str:
    """Retrieve the Brave API key from Secrets Manager (falls back to AWS CLI if boto3 is unavailable)."""
    try:
        import boto3  # type: ignore

        return boto3.client("secretsmanager", region_name=REGION).get_secret_value(
            SecretId=SECRET_ID
        )["SecretString"]
    except ImportError:
        import subprocess

        return subprocess.run(
            ["aws", "secretsmanager", "get-secret-value", "--secret-id", SECRET_ID,
             "--query", "SecretString", "--output", "text", "--region", REGION],
            capture_output=True, text=True, check=True,
        ).stdout.strip()

def get_json(url: str, key: str):
    """GET and return (HTTP status, JSON). Returns body as JSON even on HTTPError."""
    req = urllib.request.Request(
        url, headers={"Accept": "application/json", "X-Subscription-Token": key}
    )
    try:
        with urllib.request.urlopen(req, timeout=20) as r:
            return r.status, json.loads(r.read())
    except urllib.error.HTTPError as e:
        try:
            return e.code, json.loads(e.read() or b"{}")
        except Exception:
            return e.code, {}

def extract_summary_text(d: dict) -> str:
    """Extract the body text from the summary response.

    summary is an array of {type, data}, where data is a mix of strings (answer text)
    and dicts (images, entities, etc.). Concatenate only the string data entries.
    """
    parts = []
    summ = d.get("summary")
    if isinstance(summ, list):
        for p in summ:
            if isinstance(p, str):
                parts.append(p)
            elif isinstance(p, dict):
                data = p.get("data")
                if isinstance(data, str):
                    parts.append(data)
                elif isinstance(data, dict):
                    t = data.get("text") or data.get("title")
                    if isinstance(t, str):
                        parts.append(t)
    return "".join(parts).strip()

def main() -> None:
    question = sys.argv[1] if len(sys.argv) > 1 else "What is AWS Network Firewall?"
    key = get_api_key()

    # 1) Search with summary=1 to get the summarizer key
    url1 = f"{BASE}/web/search?" + urllib.parse.urlencode(
        {"q": question, "summary": 1, "count": 5}
    )
    st1, d1 = get_json(url1, key)
    print(f"[1] web/search?summary=1   http={st1}  question={question!r}")

    skey = (d1.get("summarizer") or {}).get("key")
    if not skey:
        print("    -> No summarizer key was returned (plan not supported or query not eligible for summary).")
        print("    -> Displaying regular search snippets instead:")
        for r in (d1.get("web", {}).get("results", []) or [])[:3]:
            print("    -", r.get("title"))
            print("      ", (r.get("description") or "")[:160])
        return

    # 2) Get the answer (summary) via summarizer/search
    url2 = f"{BASE}/summarizer/search?" + urllib.parse.urlencode(
        {"key": skey, "inline_references": "true"}
    )
    st2, d2 = get_json(url2, key)
    print(f"[2] summarizer/search      http={st2}")

    text = extract_summary_text(d2)
    print("=== ANSWER (Summary response from Brave) ===")
    print(text[:2000] if text else json.dumps(d2, ensure_ascii=False)[:1500])

if __name__ == "__main__":
    main()

This is the result of asking "What is AWS Network Firewall and what is it used for?" on EC2.

# Example output (python3 brave_answer.py "What is AWS Network Firewall and what is it used for?")
[1] web/search?summary=1   http=200  question='What is AWS Network Firewall and what is it used for?'
[2] summarizer/search      http=200
=== ANSWER (Summary response from Brave) ===
AWS Network Firewall is a fully managed, stateful network firewall and intrusion
detection and prevention service designed to protect Amazon Virtual Private Clouds (VPCs).
It acts as a digital guard at the perimeter of your VPC, inspecting and filtering both
incoming and outgoing network traffic ...
- Traffic Filtering and Control: ... IP addresses, ports, protocols, domain names, and URLs ...
- Intrusion Prevention and Threat Detection: ... deep packet inspection (DPI) ... Suricata-compatible rules.
- Encrypted Traffic Inspection: ... decrypt and inspect HTTPS traffic ...
- Centralized Security Management: ... integrates with AWS Firewall Manager ...
- Compliance and Data Protection: ... HIPAA, PCI DSS, and GDPR ...

Both [1] the summary=1 search (retrieving the summary key) and [2] the summary retrieval returned http=200, and instead of a list of URLs, an answer synthesized by Brave reading multiple pages was returned.
Since the summary also uses the same domain api.search.brave.com, it passes through the hole opened in Step 2 without any additional firewall changes.

8. Cleanup

Network Firewall endpoints and NAT Gateways incur charges while running even without traffic.
Be sure to delete them when you finish the hands-on.

# 1) Delete the CDK stack (deleting the stack first removes the policy, which removes the rule group reference)
cd cdk
npx cdk destroy --profile <YOUR_PROFILE>

# 2) Delete the rule group created via CLI (manual deletion required as it is outside CDK management)
aws network-firewall delete-rule-group \
  --rule-group-name allow-brave --type STATEFUL \
  --region ap-northeast-1 --profile <YOUR_PROFILE>

# 3) Secrets have a recovery grace period. For immediate deletion, use force delete
aws secretsmanager delete-secret \
  --secret-id brave-search/api-key --force-delete-without-recovery \
  --region ap-northeast-1 --profile <YOUR_PROFILE>

Closing Thoughts

We used Network Firewall to actually verify the assumption that "the Brave Search API is self-contained within a single domain, api.search.brave.com."
With only api.search.brave.com permitted, we confirmed that everything from web search to AI answers via the Summarizer passes through.

To summarize the division of responsibilities:

  • Security groups: Restrict ports. In this case, the outbound traffic from the application (EC2 / Fargate) was set to TCP443 to 0.0.0.0/0. Since Brave's IPs change and cannot be restricted, the destination IP is left open
  • Network Firewall: Restrict domains. The only hole opened here is api.search.brave.com, and all other domains are blocked

For configurations where traffic exits a VPC through a firewall, these two are layered together.
Ports are restricted by security groups, and domains are restricted by Network Firewall.
For the Brave Search API in this case, only one hole in the firewall was needed: api.search.brave.com (HTTPS / 443).
This is a domain specific to the Brave Search API. For web search APIs in general, including services like Tavily, the call destination is concentrated on a specific domain for each service, so the concept of opening just one domain corresponding to the service you use is common.
When adding web search to AI agents running on Fargate or AgentCore, you only need to open the specific single domain for the web search API you have adopted, and the full flow from search to AI answer will be reachable.

References

Share this article