Audit VPC Peering Connections and Their Routes

Blue ethernet cables plugged into a patch panel in a network cabinet

Photo by 2H Media on Unsplash

A VPC peering connections audit checks three things: which peerings exist and with whom, which route tables actually send traffic over them, and whether those routes are as narrow as they should be. Call DescribeVpcPeeringConnections for status, owners and Regions, then DescribeRouteTables for routes whose target is a pcx- ID. Active peerings with no routes, and routes in the blackhole state, are the cleanup list.

Peering connections outlive the projects that created them. A peering set up for a migration two years ago may still connect production to an account nobody owns anymore, and a route added “for now” may still send the whole peer network through it.

This example is for engineers reviewing network trust between VPCs and accounts. The script runs a VPC peering connections audit across the Regions you choose: every peering with its status, requester and accepter account and Region, DNS resolution options and route count, then every problem route. It’s report only; deleting a peering or a route affects two networks, so those changes deserve a human.

What should a VPC peering connections audit look for?

Finding Why it matters
Active peering, no routes No traffic can flow until a route table points at the peering, so it’s probably unused, but it still represents trust between two networks.
Cross-account peering Another account’s resources can reach yours over private IPs. Confirm you still know and trust that account.
Route in blackhole state The route’s target isn’t available. For peering, that’s a route to a deleted peering, or one added while the request was still pending.
Route to a whole peer VPC CIDR Every subnet in the peer can reach every host behind that route table, which is broader than most peerings need.
Pending request An unaccepted request expires after 7 days; one that keeps being recreated is worth asking about.

The broad-route check comes from the CIS Amazon Web Services Foundations Benchmark, which includes a recommendation that routing tables for VPC peering be “least access” (its number has changed between benchmark versions). The idea is to route only the subnets or hosts that need to talk, down to a single host if that’s all the peering is for.

How long do deleted and rejected peerings stay visible?

Not long, which matters for the routes they leave behind. A deleted peering stays visible for 2 hours to the side that deleted it and 2 days to the other side; a rejected one for 2 days to the requester and 2 hours to the accepter, and a failed one for 2 hours. After that, DescribeVpcPeeringConnections no longer returns it, but a route that pointed at it can remain in your route table. The script reports those as peering not found.

What do the DNS resolution options do?

By default, an instance that addresses a peer instance by its public DNS hostname gets the public IP. With DNS resolution enabled on the peering, the same hostname resolves to the private IP, so the traffic stays on the peering. Each side sets its own option: the requester’s option allows the accepter VPC to resolve the requester VPC’s hostnames, and the other way round. Both VPCs must have DNS hostnames and DNS resolution enabled. For inter-Region peering, you need the option to resolve private DNS hostnames to private IPs even for RFC 1918 ranges.

What does the script do?

  1. Picks Regions--regions, or --all-regions to use DescribeRegions. Scan both Regions of an inter-Region peering to see both sides’ routes.
  2. Lists peeringspaginateDescribeVpcPeeringConnections returns Status.Code and RequesterVpcInfo / AccepterVpcInfo with owner, Region, CIDR blocks and PeeringOptions.
  3. Collects peering routespaginateDescribeRouteTables, keeping routes whose target is a VpcPeeringConnectionId, with their State.
  4. Compares routes with the peerFor each route, the peer side is whichever VPC the route table doesn’t belong to. A route whose prefix covers a whole peer CIDR block, or more, is flagged.
  5. ReportsOne table of peerings and one of problem routes. GetCallerIdentity identifies your account so cross-account peers are named.

Prerequisites

  • Node.js 18 or later with tsx, plus @aws-sdk/client-ec2 and @aws-sdk/client-sts.
  • Run it in each account that owns peered VPCs. The script sees only your side’s route tables; the peer account’s routes need the same check there. In a shared VPC, only the VPC owner can describe its peerings.

Which IAM permissions does it need?

Three read-only EC2 actions, none of which supports resource-level permissions. GetCallerIdentity needs no permission at all.

vpc-peering-audit-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadPeeringsAndRoutes",
      "Effect": "Allow",
      "Action": ["ec2:DescribeRegions", "ec2:DescribeVpcPeeringConnections", "ec2:DescribeRouteTables"],
      "Resource": "*"
    }
  ]
}

AWS’s ReadOnlyAccess managed policy already includes these. The guide to reviewing a generated IAM policy for least privilege explains why a small dedicated policy is still worth having for audit roles.

The script for a VPC peering connections audit

find-vpc-peering-connections.ts

// find-vpc-peering-connections.ts
// Audits VPC peering connections in the Regions you choose: status, requester and accepter account and Region,
// DNS resolution options, and the route table entries that point at each pcx-. Flags active peerings with no
// routes, routes in the blackhole state or to peerings that no longer exist, and routes that send traffic to the
// whole peer VPC CIDR rather than the subnets that need it. Report only: nothing is changed.
// Usage:
//   npx tsx find-vpc-peering-connections.ts [--regions us-east-1,eu-west-1 | --all-regions]
import {
  EC2Client,
  DescribeRegionsCommand,
  paginateDescribeRouteTables,
  paginateDescribeVpcPeeringConnections,
  type VpcPeeringConnection,
  type VpcPeeringConnectionVpcInfo,
} from "@aws-sdk/client-ec2";
import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts";

const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
  const i = args.indexOf(name);
  return i >= 0 ? args[i + 1] : undefined;
};

interface PeerRoute {
  region: string;
  routeTable: string;
  vpc: string;
  destination: string;
  state: string;
  pcx: string;
}

const errText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));

// IPv4 CIDR helpers: is "inner" the same size as, or larger than, "outer" and overlapping it?
function parseCidr(cidr: string): { base: number; bits: number } | undefined {
  const m = /^(\d+)\.(\d+)\.(\d+)\.(\d+)\/(\d+)$/.exec(cidr);
  if (!m) return undefined;
  const bits = Number(m[5]);
  const ip = ((Number(m[1]) << 24) | (Number(m[2]) << 16) | (Number(m[3]) << 8) | Number(m[4])) >>> 0;
  const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
  return { base: (ip & mask) >>> 0, bits };
}
function coversWhole(route: string, vpcCidr: string): boolean {
  const r = parseCidr(route);
  const v = parseCidr(vpcCidr);
  if (!r || !v || r.bits > v.bits) return false;
  const mask = r.bits === 0 ? 0 : (0xffffffff << (32 - r.bits)) >>> 0;
  return ((v.base & mask) >>> 0) === r.base;
}

const cidrs = (info: VpcPeeringConnectionVpcInfo | undefined): string[] => {
  const set = new Set<string>((info?.CidrBlockSet ?? []).map((c) => c.CidrBlock ?? "").filter(Boolean));
  if (info?.CidrBlock) set.add(info.CidrBlock);
  return [...set];
};

async function regionsToScan(): Promise<string[]> {
  if (args.includes("--all-regions")) {
    const ec2 = new EC2Client({ region: process.env.AWS_REGION ?? "us-east-1" });
    const out = await ec2.send(new DescribeRegionsCommand({}));
    return (out.Regions ?? []).map((r) => r.RegionName).filter((r): r is string => !!r).sort();
  }
  const chosen = (flag("--regions") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
  return chosen.length ? chosen : [process.env.AWS_REGION ?? "us-east-1"];
}

async function main(): Promise<void> {
  const regions = await regionsToScan();
  const me = (await new STSClient({}).send(new GetCallerIdentityCommand({}))).Account ?? "";
  const peerings = new Map<string, VpcPeeringConnection>();
  const routes: PeerRoute[] = [];

  for (const region of regions) {
    const ec2 = new EC2Client({ region });
    try {
      for await (const page of paginateDescribeVpcPeeringConnections({ client: ec2 }, {})) {
        for (const p of page.VpcPeeringConnections ?? []) if (p.VpcPeeringConnectionId) peerings.set(p.VpcPeeringConnectionId, p);
      }
      for await (const page of paginateDescribeRouteTables({ client: ec2 }, {})) {
        for (const rt of page.RouteTables ?? []) {
          for (const r of rt.Routes ?? []) {
            if (!r.VpcPeeringConnectionId) continue;
            routes.push({
              region,
              routeTable: rt.RouteTableId ?? "?",
              vpc: rt.VpcId ?? "?",
              destination: r.DestinationCidrBlock ?? r.DestinationIpv6CidrBlock ?? r.DestinationPrefixListId ?? "?",
              state: r.State ?? "?",
              pcx: r.VpcPeeringConnectionId,
            });
          }
        }
      }
    } catch (err) {
      console.error(`${region}: ${errText(err)}`);
      process.exitCode = 1;
    }
  }

  const peeringRows = [...peerings.values()].map((p) => {
    const id = p.VpcPeeringConnectionId ?? "?";
    const req = p.RequesterVpcInfo;
    const acc = p.AccepterVpcInfo;
    const status = p.Status?.Code ?? "?";
    const mine = routes.filter((r) => r.pcx === id);
    const findings: string[] = [];
    if (status === "active" && !mine.length) findings.push("NO ROUTES in scanned Regions: unused?");
    if (status === "pending-acceptance") findings.push("pending: expires 7 days after the request");
    if (["deleted", "rejected", "expired", "failed"].includes(status) && mine.length) findings.push("routes still point here");
    if (req?.OwnerId !== acc?.OwnerId) findings.push(`cross-account (${req?.OwnerId === me ? acc?.OwnerId : req?.OwnerId})`);
    return {
      Peering: id,
      Status: status,
      Requester: `${req?.VpcId ?? "?"} ${req?.OwnerId ?? "?"} ${req?.Region ?? "?"}`,
      Accepter: `${acc?.VpcId ?? "?"} ${acc?.OwnerId ?? "?"} ${acc?.Region ?? "?"}`,
      RequesterDns: req?.PeeringOptions?.AllowDnsResolutionFromRemoteVpc ? "on" : "off",
      AccepterDns: acc?.PeeringOptions?.AllowDnsResolutionFromRemoteVpc ? "on" : "off",
      Routes: mine.length,
      Finding: findings.join("; ") || "ok",
    };
  });
  console.table(peeringRows);

  const routeRows = routes.flatMap((r) => {
    const p = peerings.get(r.pcx);
    const findings: string[] = [];
    if (r.state === "blackhole") findings.push("BLACKHOLE");
    if (!p) findings.push("peering not found (deleted?)");
    if (p) {
      const peer = r.vpc === p.RequesterVpcInfo?.VpcId ? p.AccepterVpcInfo : p.RequesterVpcInfo;
      if (cidrs(peer).some((c) => coversWhole(r.destination, c))) findings.push("whole peer VPC CIDR routed");
    }
    return findings.length ? [{ Region: r.region, RouteTable: r.routeTable, Vpc: r.vpc, Destination: r.destination, Peering: r.pcx, State: r.state, Finding: findings.join("; ") }] : [];
  });
  if (routeRows.length) console.table(routeRows);

  console.log(
    `${peeringRows.length} peering connections and ${routes.length} peering routes in ${regions.length} Region(s): ` +
      `${peeringRows.filter((r) => r.Finding.includes("NO ROUTES")).length} active with no routes, ` +
      `${routeRows.filter((r) => r.Finding.includes("BLACKHOLE") || r.Finding.includes("not found")).length} broken routes, ` +
      `${routeRows.filter((r) => r.Finding.includes("whole peer")).length} routes to a whole peer VPC CIDR.`,
  );
  console.log("Report only: nothing was changed.");
}

main().catch((err) => {
  console.error(errText(err));
  process.exit(1);
});

How do you run it?

Terminal

npm install @aws-sdk/client-ec2 @aws-sdk/client-sts
npm install --save-dev tsx typescript @types/node

# One Region
AWS_PROFILE=readonly npx tsx find-vpc-peering-connections.ts --regions us-east-1

# Every enabled Region, so inter-Region peerings show routes on both sides
AWS_PROFILE=readonly npx tsx find-vpc-peering-connections.ts --all-regions

Sample output

Output

┌─────────┬─────────────────────────┬──────────────────────┬────────────────────────────────────────────────┬────────────────────────────────────────────────┬──────────────┬─────────────┬────────┬───────────────────────────────────────────────────────────────────────────┐
│ (index) │ Peering                 │ Status               │ Requester                                      │ Accepter                                       │ RequesterDns │ AccepterDns │ Routes │ Finding                                                                   │
├─────────┼─────────────────────────┼──────────────────────┼────────────────────────────────────────────────┼────────────────────────────────────────────────┼──────────────┼─────────────┼────────┼───────────────────────────────────────────────────────────────────────────┤
│ 0       │ 'pcx-0a1b2c3d4e5f60001' │ 'active'             │ 'vpc-0aaa1111bbbb22223 111122223333 us-east-1' │ 'vpc-0ccc3333dddd44445 111122223333 us-east-1' │ 'on'         │ 'on'        │ 2      │ 'ok'                                                                      │
│ 1       │ 'pcx-0a1b2c3d4e5f60002' │ 'active'             │ 'vpc-0aaa1111bbbb22223 111122223333 us-east-1' │ 'vpc-0eee5555ffff66667 444455556666 us-east-1' │ 'off'        │ 'off'       │ 0      │ 'NO ROUTES in scanned Regions: unused?; cross-account (444455556666)'     │
│ 2       │ 'pcx-0a1b2c3d4e5f60003' │ 'pending-acceptance' │ 'vpc-0ccc3333dddd44445 111122223333 us-east-1' │ 'vpc-0999888877776666a 777788889999 eu-west-1' │ 'off'        │ 'off'       │ 0      │ 'pending: expires 7 days after the request; cross-account (777788889999)' │
└─────────┴─────────────────────────┴──────────────────────┴────────────────────────────────────────────────┴────────────────────────────────────────────────┴──────────────┴─────────────┴────────┴───────────────────────────────────────────────────────────────────────────┘
┌─────────┬─────────────┬─────────────────────────┬─────────────────────────┬───────────────┬─────────────────────────┬─────────────┬───────────────────────────────────────────┐
│ (index) │ Region      │ RouteTable              │ Vpc                     │ Destination   │ Peering                 │ State       │ Finding                                   │
├─────────┼─────────────┼─────────────────────────┼─────────────────────────┼───────────────┼─────────────────────────┼─────────────┼───────────────────────────────────────────┤
│ 0       │ 'us-east-1' │ 'rtb-0aaa000000000001a' │ 'vpc-0aaa1111bbbb22223' │ '10.1.0.0/16' │ 'pcx-0a1b2c3d4e5f60001' │ 'active'    │ 'whole peer VPC CIDR routed'              │
│ 1       │ 'us-east-1' │ 'rtb-0aaa000000000001a' │ 'vpc-0aaa1111bbbb22223' │ '10.2.0.0/16' │ 'pcx-0a1b2c3d4e5f6dead' │ 'blackhole' │ 'BLACKHOLE; peering not found (deleted?)' │
└─────────┴─────────────┴─────────────────────────┴─────────────────────────┴───────────────┴─────────────────────────┴─────────────┴───────────────────────────────────────────┘
3 peering connections and 3 peering routes in 1 Region(s): 1 active with no routes, 1 broken routes, 1 routes to a whole peer VPC CIDR.
Report only: nothing was changed.

IDs and accounts are illustrative. pcx-...0001 works, but one of its routes sends all of 10.1.0.0/16 to the peer, while the other side routes only one /24; narrow the first to the subnets that need it. pcx-...0002 connects to account 444455556666 with no routes on this side, so ask the owner whether it’s still needed. The pending request to eu-west-1 will expire on its own. The blackhole route to pcx-...dead points at a peering that no longer exists and can go.

No routes doesn’t prove no traffic if routes exist only in Regions you didn’t scan. Before deleting a peering that has routes, look at the traffic: the example to find VPCs without flow logs makes sure you have the data to check.

What should you check before removing a peering?

  • Security groups. Rules in the same Region can reference a security group in the peer VPC. Those references break when the peering goes; the scan to find security groups that are too open on common ports is a good time to review both sides.
  • Transitive assumptions. Peering isn’t transitive, and a peer can’t use your internet gateway, NAT device, VPN, Direct Connect or gateway endpoint. If a team relies on hub-and-spoke routing, they want a transit gateway; the example to find unused Transit Gateway attachments audits that side.
  • Data transfer. Creating a peering is free, and data transfer that stays within an Availability Zone is free even across accounts. Traffic across Availability Zones and Regions is charged, so a busy peering can show up on the bill.
  • Quotas. The default is 50 active peerings per VPC, adjustable up to 125, and 25 outstanding requests. Removing unused ones frees room.

Troubleshooting

  • UnauthorizedOperation. EC2’s error for a missing permission: the profile lacks one of the three actions. The guide to troubleshooting AWS IAM access denied errors helps find the blocking policy.
  • A Region errors and the rest succeed. The Region may not be enabled for your account. --all-regions uses only enabled Regions.
  • A broad route isn’t flagged. The check needs the peer’s CIDR blocks from DescribeVpcPeeringConnections. If the response has none for the peer side, the check is skipped for that route.
  • Prefix list routes. Routes that use a prefix list as the destination are listed, but the CIDR comparison only covers IPv4 CIDR destinations.

Ask ChatWithCloud instead

For a quick look, ask ChatWithCloud “List my VPC peering connections in us-east-1 with their owners and how many routes use each one.” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and summarizes the result. The guide to listing AWS resources with natural language shows how to phrase follow-ups, and connecting ChatWithCloud to an AWS profile or SSO role covers using a read-only profile per account.

Frequently asked questions

How do I find unused VPC peering connections?

List active peerings with DescribeVpcPeeringConnections, then search every route table on both sides for routes that target each pcx- ID. A peering with no routes carries no traffic.

Why is my VPC peering route a blackhole?

The route’s target isn’t available. For peering that usually means the connection was deleted, or the route was added while the request was pending acceptance and the peering isn’t active yet.

Does VPC peering cost anything?

There’s no charge to create one. Data transfer within an Availability Zone is free; transfer across Availability Zones or Regions is charged at EC2 data transfer rates.

Can two VPCs have more than one peering connection?

No. Two VPCs can have only one peering connection between them at a time, and their CIDR blocks can’t overlap.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud