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?
- Reads the subnet settings
paginateDescribeSubnetsrecordsMapPublicIpOnLaunchandAssignIpv6AddressOnCreationfor every subnet in the Region. - Summarizes security groups
paginateDescribeSecurityGroupskeeps every inbound rule that allows0.0.0.0/0or::/0, as “tcp 22”, “tcp 8000-8080” or “all”. - Loads Elastic IPs
DescribeAddressesreturns every Elastic IP in the Region, so each public IPv4 can be labeledeiporauto, and unassociated ones are listed separately. - Finds instances with a public address
paginateDescribeInstancesfor pending, running, stopping and stopped instances, reading the public IPv4 of every interface and secondary private IP, plus every IPv6 address. - 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
- Node.js 18 or later, npm and
tsx. - The
@aws-sdk/client-ec2package. - A read-only AWS profile for the Regions you scan; the guide to AWS SDK v3 credential providers such as fromIni and fromSSO explains how the SDK finds it.
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.
{
"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
// 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?
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
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?
- Open to the world on admin ports. A public address plus
tcp 22or3389from anywhere is the finding to fix first. Session Manager removes the need for inbound SSH altogether; the script to find EC2 instances not managed by Systems Manager shows which instances can use it today. - Public but nothing open. Usually a subnet that auto-assigns. Turn off
MapPublicIpOnLaunchfor new launches, and move outbound-only workloads to private subnets. A NAT gateway has its own hourly and data charge, so check the script to find idle NAT gateways costing you money before adding another. - Web servers with their own address. Put them behind a load balancer and drop the instance addresses; then confirm the listener with the script to find load balancers serving plain HTTP without a redirect.
- Harden what must stay public. Instances that keep a public address should at least require IMDSv2; the script to find EC2 instances without IMDSv2 and require it covers that. Databases are a separate check: find publicly accessible RDS instances. So are Kubernetes control planes: find EKS clusters with a public API endpoint.
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. UnauthorizedOperationon 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/0and::/0. Wide ranges such as0.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