Find Unused Security Groups in Your AWS Account

Network firewall appliance with status lights and connected ethernet cables

Photo by FlyD on Unsplash

To find unused security groups, list every group with EC2 DescribeSecurityGroups, collect the groups attached to network interfaces with DescribeNetworkInterfaces, and remove from the list any group another group’s rule references. What’s left, minus each VPC’s default group, is unused. The script below reports them and deletes only when you pass --apply.

Security groups pile up quietly. Every launch wizard run creates one, stacks leave theirs behind, and a group opened “just for a test” can sit in a VPC for years, ready to be attached to the next instance someone launches. This example is for engineers cleaning up a region who want to find unused security groups with evidence instead of guessing from names. You’ll get a TypeScript script for the AWS SDK for JavaScript v3 that checks every place a group can be in use, prints why each one is kept, and changes nothing unless you ask.

It belongs with our other AWS security audit examples with full SDK scripts. Unused groups are the easy half of security group hygiene; the other half is groups that are in use and too open, which the script to find security groups open to the internet on common ports covers.

When is a security group actually unused?

A security group is in use if anything depends on it. According to the EC2 DeleteSecurityGroup API reference, deleting a group fails with DependencyViolation when it’s associated with an instance or network interface, referenced by another security group in the same VPC, or has a VPC association. The script checks each of those before it calls a group unused:

  • Network interfaces. Groups attach to elastic network interfaces (ENIs), not to services. EC2 instances, Lambda functions in a VPC, RDS databases, load balancers, VPC endpoints and ECS tasks all show up as ENIs, so one DescribeNetworkInterfaces scan covers them. The script sets IncludeManagedResources: true so interfaces managed by AWS services are included even when managed resource visibility is set to hidden. A detached interface still holds its groups; the script to find unattached elastic network interfaces lists the ones nothing uses.
  • Rules in other groups. A group named as the source or destination of another group’s inbound or outbound rule appears in that rule’s UserIdGroupPairs. A group that only references itself doesn’t count.
  • Peered and shared VPCs. DescribeSecurityGroupReferences shows VPCs on the other side of a VPC peering or Transit Gateway connection that reference a group, and DescribeSecurityGroupVpcAssociations shows groups associated with other VPCs.
  • The default group. Every VPC has one, and it can’t be deleted, so the script skips it. It goes away with its VPC, so the script to find unused default VPCs in every Region removes those groups too.

A stopped instance keeps its network interface, so its groups count as in use. That’s the right call: the instance can start again at any time. Whether a long-stopped instance is still needed is a separate cost question, and the script to find EC2 instances stopped for weeks and still costing you answers it.

What does the script do?

  1. Lists every security grouppaginateDescribeSecurityGroups in the region from AWS_REGION or your profile.
  2. Maps attachmentspaginateDescribeNetworkInterfaces once, counting ENIs per group.
  3. Maps rule referencesWalks IpPermissions and IpPermissionsEgress of every group for referenced group IDs.
  4. Checks the remaining candidatesCalls DescribeSecurityGroupReferences and DescribeSecurityGroupVpcAssociations for groups that passed the first three checks.
  5. Deletes only on requestWith --apply, calls DeleteSecurityGroup for each UNUSED group and reports any that fail.

Prerequisites

  • Node.js 18 or later, npm and tsx.
  • The @aws-sdk/client-ec2 package.
  • A profile with a default region, or AWS_REGION set. Security groups are regional; run once per region.

Which IAM permissions does it need?

The first statement is all report mode needs. Add the second only to the profile you’ll use with --apply, scoped to your account and region (replace 123456789012 and us-east-1).

unused-security-groups-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReportUnusedSecurityGroups",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeSecurityGroups",
        "ec2:DescribeNetworkInterfaces",
        "ec2:DescribeSecurityGroupReferences",
        "ec2:DescribeSecurityGroupVpcAssociations"
      ],
      "Resource": "*"
    },
    {
      "Sid": "DeleteSecurityGroupsInOneRegion",
      "Effect": "Allow",
      "Action": "ec2:DeleteSecurityGroup",
      "Resource": "arn:aws:ec2:us-east-1:123456789012:security-group/*"
    }
  ]
}

The IAM policy generator for TypeScript AWS SDK code produces a starting point from the script itself, and the guide to review a generated IAM policy for least privilege shows how to tighten the delete statement further, for example with a tag condition.

The full script to find unused security groups

find-unused-security-groups.ts

// find-unused-security-groups.ts
// Reports security groups in one region that no network interface uses and no other group references.
// Report-only by default. Pass --apply to delete the groups it lists as UNUSED.
// Usage: npx tsx find-unused-security-groups.ts [--apply]
import {
  EC2Client,
  paginateDescribeSecurityGroups,
  paginateDescribeNetworkInterfaces,
  paginateDescribeSecurityGroupVpcAssociations,
  DescribeSecurityGroupReferencesCommand,
  DeleteSecurityGroupCommand,
  type SecurityGroup,
  type IpPermission,
} from "@aws-sdk/client-ec2";

const apply = process.argv.includes("--apply");
const ec2 = new EC2Client({}); // region from AWS_REGION or your profile

function referencedGroups(perms: IpPermission[] | undefined): string[] {
  return (perms ?? []).flatMap((p) => (p.UserIdGroupPairs ?? []).map((pair) => pair.GroupId ?? "")).filter(Boolean);
}

async function main(): Promise<void> {
  // 1. Every security group in the region.
  const groups: SecurityGroup[] = [];
  for await (const page of paginateDescribeSecurityGroups({ client: ec2 }, {})) {
    groups.push(...(page.SecurityGroups ?? []));
  }

  // 2. Groups attached to any network interface: instances (running or stopped), Lambda, RDS,
  //    load balancers, VPC endpoints, ECS tasks. IncludeManagedResources shows AWS-managed interfaces too.
  const attached = new Map<string, number>();
  for await (const page of paginateDescribeNetworkInterfaces({ client: ec2 }, { IncludeManagedResources: true })) {
    for (const eni of page.NetworkInterfaces ?? []) {
      for (const g of eni.Groups ?? []) {
        if (g.GroupId) attached.set(g.GroupId, (attached.get(g.GroupId) ?? 0) + 1);
      }
    }
  }

  // 3. Groups referenced by a rule in another group (inbound or outbound).
  const referencedBy = new Map<string, Set<string>>();
  for (const sg of groups) {
    for (const target of [...referencedGroups(sg.IpPermissions), ...referencedGroups(sg.IpPermissionsEgress)]) {
      if (target === sg.GroupId) continue; // a group that only references itself is still unused
      referencedBy.set(target, (referencedBy.get(target) ?? new Set()).add(sg.GroupId ?? ""));
    }
  }

  // 4. Candidates: not the VPC default group, not attached, not referenced.
  const candidates = groups.filter(
    (sg) => sg.GroupName !== "default" && !attached.has(sg.GroupId ?? "") && !referencedBy.has(sg.GroupId ?? ""),
  );

  // 5. Candidates can still be referenced across a VPC peering or transit gateway, or shared with another VPC.
  const blocked = new Map<string, string>();
  for (let i = 0; i < candidates.length; i += 100) {
    const ids = candidates.slice(i, i + 100).map((sg) => sg.GroupId ?? "");
    const refs = await ec2.send(new DescribeSecurityGroupReferencesCommand({ GroupId: ids }));
    for (const r of refs.SecurityGroupReferenceSet ?? []) {
      if (r.GroupId) blocked.set(r.GroupId, `referenced from ${r.ReferencingVpcId ?? "another VPC"}`);
    }
    for await (const page of paginateDescribeSecurityGroupVpcAssociations(
      { client: ec2 },
      { Filters: [{ Name: "group-id", Values: ids }] },
    )) {
      for (const a of page.SecurityGroupVpcAssociations ?? []) {
        if (a.GroupId) blocked.set(a.GroupId, `associated with ${a.VpcId ?? "another VPC"}`);
      }
    }
  }

  const rows = groups.map((sg) => {
    const id = sg.GroupId ?? "";
    const verdict =
      sg.GroupName === "default" ? "default (can't delete)"
      : attached.has(id) ? `in use (${attached.get(id)} ENIs)`
      : referencedBy.has(id) ? `referenced by ${[...(referencedBy.get(id) ?? [])].join(", ")}`
      : blocked.has(id) ? blocked.get(id) ?? "blocked"
      : "UNUSED";
    return { "Group ID": id, Name: sg.GroupName ?? "", VPC: sg.VpcId ?? "", Verdict: verdict };
  });
  console.table(rows.filter((r) => r.Verdict === "UNUSED" || r.Verdict.startsWith("referenced") || r.Verdict.startsWith("associated")));
  const unused = rows.filter((r) => r.Verdict === "UNUSED");
  console.log(`${groups.length} security groups, ${unused.length} unused.`);

  if (!apply) {
    console.log("Report only: nothing was deleted. Re-run with --apply to delete the UNUSED groups.");
    return;
  }
  for (const r of unused) {
    try {
      await ec2.send(new DeleteSecurityGroupCommand({ GroupId: r["Group ID"] }));
      console.log(`Deleted ${r["Group ID"]} (${r.Name})`);
    } catch (err) {
      // DependencyViolation means something started using the group after the scan.
      console.error(`Skipped ${r["Group ID"]}: ${err instanceof Error ? err.name + ": " + err.message : String(err)}`);
    }
  }
}

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

The table shows only the groups worth a decision: unused ones and ones kept alive by a reference. Groups attached to interfaces are counted in the summary line but not listed.

How do you run it?

Terminal

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

# Report only
AWS_PROFILE=readonly AWS_REGION=us-east-1 npx tsx find-unused-security-groups.ts

# Delete the groups listed as UNUSED (needs ec2:DeleteSecurityGroup)
AWS_PROFILE=admin AWS_REGION=us-east-1 npx tsx find-unused-security-groups.ts --apply

Sample output

Output

┌─────────┬────────────────────────┬───────────────────┬─────────────────────────┬─────────────────────────────────────────┐
│ (index) │ Group ID               │ Name              │ VPC                     │ Verdict                                 │
├─────────┼────────────────────────┼───────────────────┼─────────────────────────┼─────────────────────────────────────────┤
│ 0       │ 'sg-0a1b2c3d4e5f60718' │ 'launch-wizard-3' │ 'vpc-0123456789abcdef0' │ 'UNUSED'                                │
│ 1       │ 'sg-0b2c3d4e5f6071829' │ 'old-bastion-sg'  │ 'vpc-0123456789abcdef0' │ 'UNUSED'                                │
│ 2       │ 'sg-0c3d4e5f607182930' │ 'db-clients'      │ 'vpc-0123456789abcdef0' │ 'referenced by sg-0e5f60718293a4b5c'    │
│ 3       │ 'sg-0d4e5f60718293a4b' │ 'shared-egress'   │ 'vpc-0fedcba9876543210' │ 'referenced from vpc-0a9b8c7d6e5f40312' │
└─────────┴────────────────────────┴───────────────────┴─────────────────────────┴─────────────────────────────────────────┘
47 security groups, 2 unused.
Report only: nothing was deleted. Re-run with --apply to delete the UNUSED groups.

IDs are illustrative. db-clients has no interfaces but another group’s rule still allows traffic from it, so it stays until that rule is removed. shared-egress is referenced from a peered VPC, which a same-VPC scan alone would miss.

What should you check before you delete a security group?

DeleteSecurityGroup protects you from breaking anything that uses a group right now. It can’t protect you from things that will use it later. Before you run --apply:

  • Search your infrastructure code. Launch templates, Auto Scaling groups scaled to zero, CloudFormation, Terraform and CDK stacks can all name a group without any ENI using it today. If a template still names a deleted group, the next launch or deploy fails.
  • Check the groups referenced by others. If the referencing rule is itself stale, remove the rule first; the group becomes unused on the next run.
  • Start with obvious names. launch-wizard-* groups are created by the EC2 console and are rarely referenced by code.

AWS’s own security group best practices recommend creating only the groups you need, to decrease the risk of error. Deleting unused ones also shortens the list people pick from when they launch instances. For the wider picture, the guide to analyze your AWS security posture with an AI CLI covers the other questions worth asking; the IAM user and access key audits in the related examples below fit the same cleanup run, and so does the script to find unused IAM roles with RoleLastUsed.

Troubleshooting

  • DependencyViolation during --apply. Something started using the group between the scan and the delete, or it’s referenced in a way the scan doesn’t see. The script skips it and moves on; run the report again.
  • UnauthorizedOperation. The profile lacks one of the actions above. The steps to troubleshoot AWS IAM access denied errors walk through finding the missing action.
  • A group you know is in use shows as UNUSED. Check the region, then check whether the resource lives in another account that shares the VPC. The scan only sees interfaces the calling account can describe.
  • Instances lose SSH access after a cleanup. Deleting a group can’t detach it from a running instance, but removing a rule can. The SSH troubleshooting checklist in the related examples covers the security group side.

Ask ChatWithCloud instead

You can also find unused security groups by asking ChatWithCloud “Which security groups in us-east-1 aren’t attached to any network interface?” It writes AWS SDK for JavaScript v2 code for EC2, runs it on your machine with your AWS profile and explains the result, much like the inventory questions in the guide to list AWS resources with natural language from your terminal. It covers one profile and region per session, and it runs generated code without a confirmation step, so ask for the report, not the deletion, and connect ChatWithCloud with a read-only AWS profile. The ChatWithCloud security model explains what runs locally and what’s sent to the model.

Frequently asked questions

How do I find which resources use a security group?

Run DescribeNetworkInterfaces with the group-id filter. Every instance, database, function or load balancer that uses the group has an ENI in the result, and the ENI’s description and interface type tell you which service owns it.

Can I delete the default security group?

No. Each VPC’s default group can’t be deleted. You can remove its rules instead, so that anything launched into it by mistake gets no access.

Why can’t I delete a security group that has no instances?

Something other than an instance probably uses it, such as a Lambda function, a load balancer or an RDS database, or another group’s rule references it. Run the report above to see which.

Do unused security groups cost money?

No. AWS doesn’t charge for security groups. The risk is security: a forgotten group with wide-open rules can be attached to a new instance at any time.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud