Find EC2 Instances With Public IP Addresses

Rows of network switch ports with blue and yellow Ethernet cables plugged in

Photo by Stephen Phillips – Hostreviews.co.uk on Unsplash

To find EC2 instances with public IP addresses, call DescribeInstances in each Region and read every network interface’s Association.PublicIp and Ipv6Addresses. Compare the IPv4 addresses with DescribeAddresses to tell Elastic IPs from auto-assigned ones, and check the subnet’s MapPublicIpOnLaunch to see why the instance got one. The script below does all of it.

Public addresses tend to appear by accident: an instance launched into a default VPC, a subnet with auto-assign switched on years ago, an Elastic IP kept “just in case”. This example is for engineers and security reviewers who need to find EC2 instances with public IP addresses across Regions, understand where each address came from, and see which of those instances also accept traffic from anywhere. Deleting default VPCs nobody uses, with the script to find and delete default VPCs you aren’t using, closes the most common route.

You get a read-only TypeScript script for the AWS SDK for JavaScript v3. It belongs with the other AWS SDK v3 security and cost examples and is the natural companion to the script that can find security groups open to the internet on common ports: that one starts from the rules, this one starts from the addresses.

How does an EC2 instance end up with a public IP?

AWS’s EC2 instance IP addressing documentation describes three paths, and each behaves differently:

Source How it’s assigned What happens on stop
Auto-assigned public IPv4 By default in a default VPC; in other VPCs when the subnet’s MapPublicIpOnLaunch is on or the launch request asks for one Released; a new address is assigned on start
Elastic IP Allocated to your account and associated with the instance or one of its interfaces Stays associated, and stays billed
IPv6 When the VPC and subnet have an IPv6 CIDR and the subnet auto-assigns (AssignIpv6AddressOnCreation) or you assign one Kept until the instance is terminated

Two details matter for the report. Associating an Elastic IP releases the auto-assigned address, so an instance normally shows one or the other on its primary interface. And IPv6 addresses are globally unique: whether one is reachable from the internet depends on the subnet’s routes and the security group, not on the address itself. That’s why the script prints the world-open rules next to each instance.

What do public IPv4 addresses cost?

Since 1 February 2024, AWS charges for every public IPv4 address, whether it’s attached to a running instance or an idle Elastic IP. As of September 2026 the Amazon VPC pricing page lists $0.005 per address per hour for both in-use and idle addresses, in us-east-1 and every other Region. The charge is for public IPv4; the pricing page lists no per-address charge for IPv6.

Public IPv4 addresses Per hour Per month (730 h) Per year
1 $0.005 $3.65 $43.80
40 $0.20 $146.00 $1,752.00
250 $1.25 $912.50 $10,950.00

The arithmetic: 40 addresses × $0.005 × 730 hours = $146.00 a month. It rarely makes a bill on its own, but it’s pure overhead for instances that never needed to be reachable. An Elastic IP that isn’t associated with anything costs the same as one that is; the script to find and release unassociated Elastic IP addresses handles that half of the cleanup.

What does the script do?

  1. Reads the subnet settingspaginateDescribeSubnets records MapPublicIpOnLaunch and AssignIpv6AddressOnCreation for every subnet in the Region.
  2. Summarizes security groupspaginateDescribeSecurityGroups keeps every inbound rule that allows 0.0.0.0/0 or ::/0, as “tcp 22”, “tcp 8000-8080” or “all”.
  3. Loads Elastic IPsDescribeAddresses returns every Elastic IP in the Region, so each public IPv4 can be labeled eip or auto, and unassociated ones are listed separately.
  4. Finds instances with a public addresspaginateDescribeInstances for pending, running, stopping and stopped instances, reading the public IPv4 of every interface and secondary private IP, plus every IPv6 address.
  5. Reports onlyPrints a table, a count of instances that are both public and world-open, an estimated monthly IPv4 charge and optional CSV. It never releases or disassociates anything.

Prerequisites

Which IAM permissions does it need?

Four EC2 describe actions. Describe calls don’t support resource-level permissions, so they use "Resource": "*". AWS’s managed ReadOnlyAccess policy also covers them.

public-ip-audit-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadAddressesAndNetworking",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeInstances",
        "ec2:DescribeAddresses",
        "ec2:DescribeSubnets",
        "ec2:DescribeSecurityGroups"
      ],
      "Resource": "*"
    }
  ]
}

The IAM policy generator for TypeScript AWS SDK code can derive a policy like this from the script, and the checklist to review a generated IAM policy for least privilege covers what to check before attaching it.

The full script to find EC2 instances with public IP addresses

find-ec2-public-ips.ts

// find-ec2-public-ips.ts
// Lists EC2 instances that have a public IPv4 address (auto-assigned or Elastic IP) or an IPv6 address,
// shows whether the subnet hands out public IPs on launch, and which world-open security group rules apply.
// Read-only: it never releases, disassociates or modifies anything.
// Usage: npx tsx find-ec2-public-ips.ts [--regions us-east-1,eu-west-1] [--csv public-ips.csv]
import { writeFileSync } from "node:fs";
import {
  EC2Client,
  DescribeAddressesCommand,
  paginateDescribeInstances,
  paginateDescribeSecurityGroups,
  paginateDescribeSubnets,
  type IpPermission,
} from "@aws-sdk/client-ec2";

const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
  const i = args.indexOf(name);
  return i >= 0 ? args[i + 1] : undefined;
};
const regions = (flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1").split(",").map((r) => r.trim()).filter(Boolean);
const csvPath = flag("--csv");

// Public IPv4 list price per address-hour (Amazon VPC pricing, checked September 2026). 730 hours ≈ 1 month.
const IPV4_HOURLY = 0.005;
const HOURS_PER_MONTH = 730;

interface Row {
  Region: string;
  Instance: string;
  Name: string;
  State: string;
  PublicIPv4: string;
  Kind: string; // "auto" (released on stop) or "eip" (stays allocated and billed)
  IPv6: string;
  Subnet: string;
  AutoAssign: string; // subnet MapPublicIpOnLaunch / AssignIpv6AddressOnCreation
  OpenToWorld: string;
}

// "tcp 22", "tcp 8000-8080", "all" for rules that allow 0.0.0.0/0 or ::/0.
function worldOpen(perms: IpPermission[] = []): string[] {
  const out: string[] = [];
  for (const p of perms) {
    const open = (p.IpRanges ?? []).some((r) => r.CidrIp === "0.0.0.0/0") || (p.Ipv6Ranges ?? []).some((r) => r.CidrIpv6 === "::/0");
    if (!open) continue;
    if (p.IpProtocol === "-1") out.push("all");
    else if (p.FromPort === p.ToPort) out.push(`${p.IpProtocol} ${p.FromPort}`);
    else out.push(`${p.IpProtocol} ${p.FromPort}-${p.ToPort}`);
  }
  return out;
}

async function scanRegion(region: string): Promise<{ rows: Row[]; idleEips: string[] }> {
  const ec2 = new EC2Client({ region });

  const subnets = new Map<string, { v4: boolean; v6: boolean }>();
  for await (const page of paginateDescribeSubnets({ client: ec2 }, {})) {
    for (const s of page.Subnets ?? []) {
      if (s.SubnetId) subnets.set(s.SubnetId, { v4: s.MapPublicIpOnLaunch === true, v6: s.AssignIpv6AddressOnCreation === true });
    }
  }

  const openRules = new Map<string, string[]>();
  for await (const page of paginateDescribeSecurityGroups({ client: ec2 }, {})) {
    for (const g of page.SecurityGroups ?? []) {
      if (g.GroupId) openRules.set(g.GroupId, worldOpen(g.IpPermissions));
    }
  }

  // DescribeAddresses isn't paginated: it returns every Elastic IP in the Region.
  const { Addresses = [] } = await ec2.send(new DescribeAddressesCommand({}));
  const eips = new Set(Addresses.map((a) => a.PublicIp).filter((ip): ip is string => Boolean(ip)));
  const idleEips = Addresses.filter((a) => !a.AssociationId).map((a) => a.PublicIp ?? "");

  const rows: Row[] = [];
  const pages = paginateDescribeInstances(
    { client: ec2 },
    { Filters: [{ Name: "instance-state-name", Values: ["pending", "running", "stopping", "stopped"] }] },
  );
  for await (const page of pages) {
    for (const res of page.Reservations ?? []) {
      for (const inst of res.Instances ?? []) {
        const v4 = new Set<string>();
        const v6 = new Set<string>();
        for (const nic of inst.NetworkInterfaces ?? []) {
          if (nic.Association?.PublicIp) v4.add(nic.Association.PublicIp);
          for (const priv of nic.PrivateIpAddresses ?? []) if (priv.Association?.PublicIp) v4.add(priv.Association.PublicIp);
          for (const a of nic.Ipv6Addresses ?? []) if (a.Ipv6Address) v6.add(a.Ipv6Address);
        }
        if (inst.PublicIpAddress) v4.add(inst.PublicIpAddress);
        if (!v4.size && !v6.size) continue; // private only

        const subnet = subnets.get(inst.SubnetId ?? "");
        const auto = [subnet?.v4 ? "ipv4" : "", subnet?.v6 ? "ipv6" : ""].filter(Boolean).join("+") || "off";
        const open = new Set((inst.SecurityGroups ?? []).flatMap((g) => openRules.get(g.GroupId ?? "") ?? []));
        rows.push({
          Region: region,
          Instance: inst.InstanceId ?? "",
          Name: inst.Tags?.find((t) => t.Key === "Name")?.Value ?? "",
          State: inst.State?.Name ?? "",
          PublicIPv4: [...v4].join(" "),
          Kind: [...v4].map((ip) => (eips.has(ip) ? "eip" : "auto")).join(" "),
          IPv6: [...v6].join(" "),
          Subnet: inst.SubnetId ?? "",
          AutoAssign: auto,
          OpenToWorld: [...open].join(", ") || "none",
        });
      }
    }
  }
  return { rows, idleEips };
}

function toCsv(rows: Row[]): string {
  const cols: (keyof Row)[] = ["Region", "Instance", "Name", "State", "PublicIPv4", "Kind", "IPv6", "Subnet", "AutoAssign", "OpenToWorld"];
  const cell = (v: string) => `"${v.replace(/"/g, '""')}"`;
  return [cols.join(","), ...rows.map((r) => cols.map((c) => cell(r[c])).join(","))].join("\n") + "\n";
}

async function main(): Promise<void> {
  const rows: Row[] = [];
  let ipv4Count = 0;
  for (const region of regions) {
    const { rows: found, idleEips } = await scanRegion(region);
    rows.push(...found);
    ipv4Count += found.reduce((n, r) => n + (r.PublicIPv4 ? r.PublicIPv4.split(" ").length : 0), 0) + idleEips.length;
    if (idleEips.length) console.log(`${region}: ${idleEips.length} Elastic IPs not associated with anything: ${idleEips.join(", ")}`);
  }
  console.table(rows.map(({ Subnet, ...shown }) => shown));
  const exposed = rows.filter((r) => r.OpenToWorld !== "none");
  console.log(`${rows.length} instances with a public address in ${regions.join(", ")}; ${exposed.length} also have world-open inbound rules`);
  const monthly = ipv4Count * IPV4_HOURLY * HOURS_PER_MONTH;
  console.log(`${ipv4Count} public IPv4 addresses (incl. idle EIPs) ≈ $${monthly.toFixed(2)}/month at $${IPV4_HOURLY}/hour each`);
  if (csvPath) {
    writeFileSync(csvPath, toCsv(rows));
    console.log(`Wrote ${rows.length} rows to ${csvPath}`);
  }
  console.log("Report only: no address was released and no instance was changed.");
}

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

How do you run it?

Terminal

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

# Two Regions, table only
AWS_PROFILE=readonly npx tsx find-ec2-public-ips.ts --regions us-east-1,eu-west-1

# Same scan, saved as CSV for the security review
AWS_PROFILE=readonly npx tsx find-ec2-public-ips.ts --regions us-east-1,eu-west-1 --csv public-ips.csv

Sample output

Output

us-east-1: 1 Elastic IPs not associated with anything: 3.210.44.17
┌─────────┬─────────────┬───────────────────────┬──────────────┬───────────┬─────────────────┬────────┬─────────────────────────┬────────────┬───────────────────┐
│ (index) │ Region      │ Instance              │ Name         │ State     │ PublicIPv4      │ Kind   │ IPv6                    │ AutoAssign │ OpenToWorld       │
├─────────┼─────────────┼───────────────────────┼──────────────┼───────────┼─────────────────┼────────┼─────────────────────────┼────────────┼───────────────────┤
│ 0       │ 'us-east-1' │ 'i-0a1b2c3d4e5f60718' │ 'bastion'    │ 'running' │ '54.172.10.201' │ 'eip'  │ ''                      │ 'off'      │ 'tcp 22'          │
│ 1       │ 'us-east-1' │ 'i-0b2c3d4e5f6071829' │ 'web-legacy' │ 'running' │ '3.91.188.42'   │ 'auto' │ ''                      │ 'ipv4'     │ 'tcp 80, tcp 443' │
│ 2       │ 'us-east-1' │ 'i-0c3d4e5f607182930' │ 'worker-7'   │ 'running' │ '44.201.9.77'   │ 'auto' │ ''                      │ 'ipv4'     │ 'none'            │
│ 3       │ 'eu-west-1' │ 'i-0d4e5f60718293a41' │ 'api-v6'     │ 'running' │ ''              │ ''     │ '2a05:d018:1f::4c21'    │ 'ipv6'     │ 'tcp 443'         │
│ 4       │ 'eu-west-1' │ 'i-0e5f60718293a4b52' │ 'reporting'  │ 'stopped' │ '52.49.3.160'   │ 'eip'  │ ''                      │ 'off'      │ 'all'             │
└─────────┴─────────────┴───────────────────────┴──────────────┴───────────┴─────────────────┴────────┴─────────────────────────┴────────────┴───────────────────┘
5 instances with a public address in us-east-1, eu-west-1; 4 also have world-open inbound rules
5 public IPv4 addresses (incl. idle EIPs) ≈ $18.25/month at $0.005/hour each
Report only: no address was released and no instance was changed.

IDs and addresses are illustrative. Read the rows by risk: reporting is stopped yet still holds a billed Elastic IP behind a group that allows all traffic, and worker-7 has a public address it probably never uses, because its subnet auto-assigns one and nothing is open to the world.

What should you do with the results?

Before you remove an address: an auto-assigned public IPv4 can’t be recovered once released, and DNS records, allow-lists at partners or SSH configs may point at it. Search for the address first.

Troubleshooting

  • A stopped instance shows no address. Expected: auto-assigned addresses are released on stop. Elastic IPs and IPv6 addresses survive a stop, so a stopped instance in the report shows eip, an IPv6 address, or both.
  • UnauthorizedOperation on one call. The profile lacks one of the four describe actions. The steps to troubleshoot AWS IAM access denied errors decode the message.
  • “OpenToWorld” says none but the instance is reachable. The script only flags 0.0.0.0/0 and ::/0. Wide ranges such as 0.0.0.0/1, prefix lists and rules that reference other groups aren’t flagged.
  • A Region fails with an authentication error. Opt-in Regions that aren’t enabled for the account reject API calls; leave them out of --regions.

Ask ChatWithCloud instead

For a quick answer in one Region, ask ChatWithCloud “Which EC2 instances in us-east-1 have a public IP address, and which of them allow SSH from anywhere?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile, and explains the result; the page on how ChatWithCloud runs AWS SDK code locally shows each step. It uses one profile and one Region per session and runs generated code without a confirmation step, so connect ChatWithCloud to a read-only AWS profile first. For a multi-Region CSV you rerun every quarter, keep the script.

Frequently asked questions

How do I list EC2 instances with a public IP using the AWS CLI?

Run aws ec2 describe-instances --query "Reservations[].Instances[?PublicIpAddress].[InstanceId,PublicIpAddress]" --output table in each Region. It shows the primary public IPv4 only; secondary interfaces and IPv6 need the per-interface fields the script reads.

How can I tell if a public IP is an Elastic IP?

Compare it with the output of DescribeAddresses, which lists every Elastic IP in the Region. Anything not in that list was auto-assigned from Amazon’s pool and will change when the instance stops and starts.

Does a public IP mean my instance is exposed to the internet?

Only if the route table sends traffic to an internet gateway and a security group rule allows it in. A public address with no open inbound rules still costs money and widens what a later rule change can expose.

Do I pay for public IPv4 on stopped instances?

Not for auto-assigned addresses, which are released on stop. An Elastic IP stays allocated and is billed at the same hourly rate whether the instance runs or not.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud