Photo by Troy Bridges on Unsplash
To find unattached network interfaces, call EC2 DescribeNetworkInterfaces in each Region with the filter status=available. Then skip interfaces with RequesterManaged set to true or an InterfaceType other than interface: AWS services own those. What’s left are ENIs you or your tooling created and never cleaned up. Any with an Elastic IP cost $0.005 an hour.
Elastic network interfaces (ENIs) pile up quietly. A secondary interface detached from an instance that was later terminated, a failover interface nobody moved back, a leftover from a Terraform run that failed halfway: none of them show up on an instance page. This example is for engineers who want to find unattached network interfaces across every Region, understand which are safe to remove, and delete them only after a review.
You’ll get a TypeScript script for the AWS SDK for JavaScript v3 that is report-only by default and deletes only with --apply. It belongs with the other cleanup scripts in our AWS SDK v3 cost and cleanup examples.
What do unattached network interfaces cost?
The interface itself isn’t the line on your bill. The cost comes from what it holds:
- A public IPv4 address. As of September 2026, the Amazon VPC pricing page and the AWS Price List for us-east-1 charge $0.005 per public IPv4 address per hour, whether it’s in use or idle. An Elastic IP left on an unattached ENI is 0.005 × 730 hours = $3.65 a month, or $43.80 a year, for an address nothing answers on.
- Private IP addresses. Every ENI keeps at least one private IP from its subnet. In small subnets, leaked interfaces can leave new instances or pods without an address, and every ENI counts toward your network interface quota (hit it and creates fail with
NetworkInterfaceLimitExceeded); the script to find the first free IP address in a VPC subnet shows how much room is left. - Security groups. An ENI keeps its security groups in use, so they can’t be deleted. That’s why the script to find unused security groups in your AWS account often reports groups as attached to interfaces nothing uses.
Which unattached ENIs should you leave alone?
An ENI in the available state isn’t automatically garbage. InterfaceType and RequesterManaged tell you who owns it:
| What you see | Who owns it | What to do |
|---|---|---|
RequesterManaged: true |
An AWS service created it for a resource such as an RDS instance, NAT gateway or interface VPC endpoint | Leave it. You can’t detach or modify it; the service deletes it with the resource. |
InterfaceType: lambda |
Lambda, as a Hyperplane ENI shared by functions with the same subnets and security groups | Leave it. Lambda reclaims it after a function has been idle for 14 days, or within 20 minutes of the VPC configuration being removed from the last function using it. |
Other types (nat_gateway, vpc_endpoint, efs, load_balancer…) |
The resource named by the type | Delete the resource if it’s unused, not the ENI. For endpoints, the script to find unused VPC interface endpoints checks traffic first. |
Description starts with aws-K8S- |
The Amazon VPC CNI plugin on an EKS node | Check the cluster. These can show as available briefly while the plugin attaches them, and the plugin has its own leaked-ENI cleanup. |
interface, not requester-managed |
You, or a tool running as you | Candidate for deletion after a check. |
One exception: if you delete a Lambda function’s execution role before Lambda has removed its Hyperplane ENI, Lambda can’t delete it, and you have to. Such an ENI keeps InterfaceType: lambda, so check it against your functions by hand.
What does the script do?
- Lists Regions
DescribeRegions, or--regions=. - Finds unattached ENIs
paginateDescribeNetworkInterfaceswithstatus=available. - Classifies each oneRequester-managed, service-typed, EKS CNI, tagged
keep=true, orCANDIDATE. - Reports public IPsShows the Elastic IP and allocation ID on each ENI and estimates their monthly cost.
- Deletes only on requestWith
--apply,DeleteNetworkInterfaceon candidates only.
Prerequisites
- Node.js 18 or later, npm and
tsx, plus the@aws-sdk/client-ec2package. - An AWS profile, for example one loaded with the AWS SDK v3 credential providers for profiles and SSO.
- Tag any interface you keep on purpose (a failover interface, a licensed MAC address) with
keep=truebefore you run--apply.
Which IAM permissions does it need?
The first statement is enough for the report; the second is only for --apply. Replace 123456789012 with your account ID.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReportInterfaces",
"Effect": "Allow",
"Action": ["ec2:DescribeRegions", "ec2:DescribeNetworkInterfaces"],
"Resource": "*"
},
{
"Sid": "DeleteInterfaces",
"Effect": "Allow",
"Action": "ec2:DeleteNetworkInterface",
"Resource": "arn:aws:ec2:*:123456789012:network-interface/*"
}
]
}
Use the IAM policy generator for TypeScript code if you extend the script and need a fresh draft.
The full script to find unattached network interfaces
// find-unattached-network-interfaces.ts
// Lists elastic network interfaces (ENIs) with status "available" (not attached to anything) in every
// enabled Region, separates the ones AWS services manage from the ones you created, and shows which
// still hold an Elastic IP address that you pay for by the hour.
// Report-only by default. --apply deletes the unattached ENIs that are yours (type "interface",
// not requester-managed, not tagged keep=true). Elastic IPs are disassociated, not released.
// Usage: npx tsx find-unattached-network-interfaces.ts [--regions=us-east-1,eu-west-1] [--apply]
import {
EC2Client,
DeleteNetworkInterfaceCommand,
DescribeRegionsCommand,
paginateDescribeNetworkInterfaces,
type NetworkInterface,
} from "@aws-sdk/client-ec2";
const args = process.argv.slice(2);
const apply = args.includes("--apply");
const regionArg = args.find((a) => a.startsWith("--regions="))?.split("=")[1];
const IPV4_HOURLY = 0.005; // public IPv4 address per hour, us-east-1 (AWS Price List, September 2026)
const HOURS_PER_MONTH = 730;
interface Row { Region: string; ENI: string; Type: string; Subnet: string; Description: string; PublicIp: string; Verdict: string }
async function listRegions(): Promise<string[]> {
if (regionArg) return regionArg.split(",").map((r) => r.trim()).filter(Boolean);
const out = await new EC2Client({}).send(new DescribeRegionsCommand({}));
return (out.Regions ?? []).map((r) => r.RegionName ?? "").filter(Boolean).sort();
}
function classify(eni: NetworkInterface): string {
const keep = eni.TagSet?.some((t) => t.Key?.toLowerCase() === "keep" && t.Value?.toLowerCase() === "true");
if (eni.RequesterManaged) return `managed by ${eni.RequesterId ?? "an AWS service"}: leave it`;
if (eni.InterfaceType && eni.InterfaceType !== "interface") return `${eni.InterfaceType} ENI: remove the owning resource instead`;
if ((eni.Description ?? "").startsWith("aws-K8S-")) return "EKS VPC CNI: check the cluster first";
if (keep) return "tagged keep=true: skipped";
return "CANDIDATE";
}
async function scanRegion(region: string): Promise<Row[]> {
const ec2 = new EC2Client({ region });
const rows: Row[] = [];
for await (const page of paginateDescribeNetworkInterfaces({ client: ec2 }, { Filters: [{ Name: "status", Values: ["available"] }] })) {
for (const eni of page.NetworkInterfaces ?? []) {
rows.push({
Region: region,
ENI: eni.NetworkInterfaceId ?? "",
Type: eni.InterfaceType ?? "",
Subnet: eni.SubnetId ?? "",
Description: (eni.Description ?? "").slice(0, 40),
PublicIp: eni.Association?.PublicIp ? `${eni.Association.PublicIp} (${eni.Association.AllocationId ?? "no alloc id"})` : "-",
Verdict: classify(eni),
});
}
}
if (apply) {
for (const r of rows.filter((x) => x.Verdict === "CANDIDATE")) {
try {
await ec2.send(new DeleteNetworkInterfaceCommand({ NetworkInterfaceId: r.ENI }));
r.Verdict = r.PublicIp === "-" ? "deleted" : "deleted: Elastic IP still allocated, release it";
} catch (err) {
r.Verdict = `failed: ${err instanceof Error ? err.name : String(err)}`;
}
}
}
return rows;
}
async function main(): Promise<void> {
const rows: Row[] = [];
for (const region of await listRegions()) {
try {
rows.push(...(await scanRegion(region)));
} catch (err) {
console.error(`${region}: skipped (${err instanceof Error ? `${err.name}: ${err.message}` : String(err)})`);
}
}
console.table(rows);
const candidates = rows.filter((r) => r.Verdict === "CANDIDATE");
const withIp = rows.filter((r) => r.PublicIp !== "-").length;
const ipCost = withIp * IPV4_HOURLY * HOURS_PER_MONTH;
console.log(`${rows.length} unattached ENIs; ${candidates.length} are yours to review. ${withIp} hold a public IPv4 address (~$${ipCost.toFixed(2)}/month).`);
if (!apply) console.log(`Report only: nothing changed. --apply would delete ${candidates.length} ENI(s).`);
}
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
# Report only, every enabled Region
AWS_PROFILE=readonly npx tsx find-unattached-network-interfaces.ts
# Delete the candidates in one Region after reviewing the report
AWS_PROFILE=admin npx tsx find-unattached-network-interfaces.ts --regions=us-east-1 --apply
Sample output
┌─────────┬─────────────┬─────────────────────────┬─────────────┬───────────────────┬──────────────────────────────────┬─────────────────────────────────────────────┬──────────────────────────────────────────────────┐
│ (index) │ Region │ ENI │ Type │ Subnet │ Description │ PublicIp │ Verdict │
├─────────┼─────────────┼─────────────────────────┼─────────────┼───────────────────┼──────────────────────────────────┼─────────────────────────────────────────────┼──────────────────────────────────────────────────┤
│ 0 │ 'eu-west-1' │ 'eni-0a1b2c3d4e5f60718' │ 'interface' │ 'subnet-0f1e2d3c' │ 'failover-vip' │ '-' │ 'tagged keep=true: skipped' │
│ 1 │ 'us-east-1' │ 'eni-0b2c3d4e5f6071829' │ 'interface' │ 'subnet-0a9b8c7d' │ '' │ '-' │ 'CANDIDATE' │
│ 2 │ 'us-east-1' │ 'eni-0c3d4e5f607182930' │ 'interface' │ 'subnet-0a9b8c7d' │ 'old-bastion-eth1' │ '203.0.113.25 (eipalloc-0d4e5f6a7b8c9d0e1)' │ 'CANDIDATE' │
│ 3 │ 'us-east-1' │ 'eni-0d4e5f60718293a41' │ 'lambda' │ 'subnet-0b1c2d3e' │ 'AWS Lambda VPC ENI-orders-sync' │ '-' │ 'lambda ENI: remove the owning resource instead' │
│ 4 │ 'us-east-1' │ 'eni-0e5f60718293a4b52' │ 'interface' │ 'subnet-0b1c2d3e' │ 'aws-K8S-i-0f1a2b3c4d5e6f708' │ '-' │ 'EKS VPC CNI: check the cluster first' │
└─────────┴─────────────┴─────────────────────────┴─────────────┴───────────────────┴──────────────────────────────────┴─────────────────────────────────────────────┴──────────────────────────────────────────────────┘
5 unattached ENIs; 2 are yours to review. 1 hold a public IPv4 address (~$3.65/month).
Report only: nothing changed. --apply would delete 2 ENI(s).
IDs and addresses are illustrative. Two interfaces are candidates, and old-bastion-eth1 still holds an Elastic IP. The Lambda and EKS interfaces are reported so you know they exist, but the script won’t touch them.
What happens to the Elastic IP when you delete the ENI?
Deleting a network interface releases its private IPs and disassociates any Elastic IP, but the Elastic IP stays allocated to your account and keeps costing $0.005 an hour. The script prints a reminder for each one. Release the address afterwards, or reuse it, with the script to find and release unassociated Elastic IP addresses.
Before running --apply, check the description and tags of each candidate. An interface with a description like old-bastion-eth1 tells you where it came from; one with no description and no tags is usually a leftover from an automation run. Unattached interfaces often sit next to other leftovers from the same project, which the scripts to find and tag unattached EBS volumes and find EC2 instances stopped for weeks pick up.
Troubleshooting
InvalidNetworkInterfaceID.NotFoundduring--apply. Something else deleted the interface between the report and the delete, often the service that owned it. Nothing to do.InvalidNetworkInterface.InUse. The interface was attached after the scan. Run the report again.UnauthorizedOperation. The profile lacksec2:DeleteNetworkInterface, or a condition in your policy blocks it. The guide to troubleshoot IAM access denied errors in AWS explains how to decode the message.- The same ENIs come back after you delete them. Something keeps creating them, such as an IaC stack or a script that creates interfaces and fails before attaching them. Fix the source, then clean up.
Ask ChatWithCloud instead
You can also ask ChatWithCloud “Which network interfaces in us-east-1 are not attached to anything?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your AWS profile and explains the result, like the cost questions in the guide to ask AI why your AWS bill increased. It runs generated code without a confirmation step, so ask it for a list rather than a deletion and connect ChatWithCloud with a read-only AWS profile. The ChatWithCloud security model explains what runs locally.
Frequently asked questions
What does available status mean for a network interface?
It isn’t attached to an instance or another resource. The other states are associated, attaching, in-use and detaching.
Can I delete a requester-managed network interface?
Not while the service still uses it. Delete the owning resource, such as the NAT gateway or VPC endpoint, and the service removes the interface. If a service detached one but didn’t delete it, you can delete it yourself.
Does deleting an ENI release its Elastic IP?
It disassociates the Elastic IP, but the address stays allocated to your account and is still billed until you release it.
Why can’t I delete a security group that nothing uses?
Often an unattached ENI still references it. Delete the interface or change its security groups first.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud