Find Unused EC2 Key Pairs in Every Region

A pile of old brass and steel keys lying on a wooden table

Photo by Andre William on Unsplash

To find unused EC2 key pairs, list them with DescribeKeyPairs in each Region, then collect every KeyName referenced by instances that aren’t terminated, by the latest and default versions of your launch templates, and by Auto Scaling launch configurations. A key pair that appears in none of them is unused and can be deleted from EC2 without affecting running servers.

Key pairs pile up. Every tutorial, every contractor and every “quick test instance” leaves one behind, and the console gives no hint which ones still matter. This example is for engineers who want to find unused EC2 key pairs across all Regions of an account and clean them up without breaking an Auto Scaling group at 3 a.m.

You’ll get a TypeScript script for the AWS SDK for JavaScript v3 that grades every key pair as unused, template-only or in use, shows who uses it, and deletes only the unused ones older than a threshold you choose when you pass --apply. It sits with the other runnable AWS security and cost scripts in our examples hub.

What does deleting an EC2 key pair actually remove?

An EC2 key pair has two halves. AWS stores the public key; you keep the private key. When an instance launches with a key pair, EC2 copies the public key into the default user’s ~/.ssh/authorized_keys on the instance, and from then on the SSH server reads that file, not EC2. The OpenSSH sshd manual on the authorized_keys file format describes how that file controls public key logins.

That leads to two facts that shape the cleanup:

  • Deleting a key pair in EC2 doesn’t lock anyone out. Existing instances keep the public key in authorized_keys, and anyone holding the private key can still connect.
  • Deleting it does break future launches. A launch template or launch configuration that names a missing key pair fails to launch, so an Auto Scaling group can’t replace an unhealthy instance.

So the risk in this cleanup is not SSH access to today’s servers; it’s tomorrow’s scale-out. That’s why the script treats a key named only by a template as its own category instead of calling it unused.

Why remove unused key pairs at all?

The cost is zero, but the inventory matters. Every key pair is a private key file somewhere: on a laptop, in a CI secret, in an old wiki page. Removing the public half from EC2 means nobody can pick that key from the list and launch a new instance that trusts it, and it shortens the list you have to reason about when someone leaves the team.

How does the script decide a key pair is unused?

  1. Lists key pairsDescribeKeyPairs per Region returns KeyName, KeyPairId, KeyType (rsa or ed25519) and CreateTime, which is the import date for imported keys.
  2. Checks instancesDescribeInstances (paginated) collects KeyName from every instance except terminated ones. Stopped instances count as users: someone may start them again.
  3. Checks launch templatesDescribeLaunchTemplateVersions with Versions: ["$Latest", "$Default"] and no template ID returns the latest and default version of every template in one paginated call.
  4. Checks launch configurationsDescribeLaunchConfigurations from the Auto Scaling API for groups that still use the legacy launch configurations.
  5. Grades and deletesUNUSED if nothing references the key and it’s at least --min-age-days old; with --apply, DeleteKeyPair by KeyPairId.

IncludeManagedResources: true makes sure instances and templates owned by AWS services, such as EKS managed node groups, are included even when managed resource visibility is set to hidden.

Prerequisites

Which IAM permissions does it need?

unused-key-pairs-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "FindKeyPairReferences",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeRegions",
        "ec2:DescribeKeyPairs",
        "ec2:DescribeInstances",
        "ec2:DescribeLaunchTemplateVersions",
        "autoscaling:DescribeLaunchConfigurations"
      ],
      "Resource": "*"
    },
    {
      "Sid": "DeleteKeyPairsOnlyWithApply",
      "Effect": "Allow",
      "Action": "ec2:DeleteKeyPair",
      "Resource": "arn:aws:ec2:*:*:key-pair/*"
    }
  ]
}

EC2 and Auto Scaling Describe* actions don’t support resource-level permissions, so they need "Resource": "*". Drop the second statement for a report-only role. To see what the profile you’re using can already do, run the script to find out the permissions of your currently assumed IAM role.

The script to find unused EC2 key pairs

find-unused-ec2-key-pairs.ts

// find-unused-ec2-key-pairs.ts
// Finds EC2 key pairs that no instance (any state except terminated), launch template ($Latest or
// $Default version) or Auto Scaling launch configuration references, in every enabled Region.
// Changes nothing unless you pass --apply, which deletes unused key pairs older than --min-age-days.
// Deleting a key pair only removes the public key stored in EC2; it doesn't touch authorized_keys on instances.
// Usage: npx tsx find-unused-ec2-key-pairs.ts [--regions=us-east-1,eu-west-1] [--min-age-days=30] [--apply]
import {
  EC2Client,
  DeleteKeyPairCommand,
  DescribeKeyPairsCommand,
  DescribeRegionsCommand,
  paginateDescribeInstances,
  paginateDescribeLaunchTemplateVersions,
} from "@aws-sdk/client-ec2";
import { AutoScalingClient, paginateDescribeLaunchConfigurations } from "@aws-sdk/client-auto-scaling";

const args = process.argv.slice(2);
const apply = args.includes("--apply");
const regionArg = args.find((a) => a.startsWith("--regions="))?.split("=")[1];
const minAgeDays = Number(args.find((a) => a.startsWith("--min-age-days="))?.split("=")[1] ?? "30");

interface KeyRow {
  Region: string;
  KeyName: string;
  Type: string;
  AgeDays: number;
  Verdict: string;
  UsedBy: 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({})); // enabled Regions only
  return (out.Regions ?? []).map((r) => r.RegionName ?? "").filter(Boolean).sort();
}

function addUse(uses: Map<string, string[]>, keyName: string | undefined, what: string): void {
  if (!keyName) return;
  uses.set(keyName, [...(uses.get(keyName) ?? []), what]);
}

// Every place in the Region that names a key pair.
async function keyReferences(ec2: EC2Client, asg: AutoScalingClient): Promise<Map<string, string[]>> {
  const uses = new Map<string, string[]>();
  for await (const page of paginateDescribeInstances({ client: ec2 }, { IncludeManagedResources: true })) {
    for (const r of page.Reservations ?? []) {
      for (const i of r.Instances ?? []) {
        if (i.State?.Name === "terminated") continue;
        addUse(uses, i.KeyName, `${i.InstanceId} (${i.State?.Name})`);
      }
    }
  }
  for await (const page of paginateDescribeLaunchTemplateVersions(
    { client: ec2 },
    { Versions: ["$Latest", "$Default"], IncludeManagedResources: true },
  )) {
    for (const v of page.LaunchTemplateVersions ?? []) {
      addUse(uses, v.LaunchTemplateData?.KeyName, `template ${v.LaunchTemplateName} v${v.VersionNumber}`);
    }
  }
  for await (const page of paginateDescribeLaunchConfigurations({ client: asg }, {})) {
    for (const lc of page.LaunchConfigurations ?? []) addUse(uses, lc.KeyName, `launch config ${lc.LaunchConfigurationName}`);
  }
  return uses;
}

async function main(): Promise<void> {
  const rows: KeyRow[] = [];
  const toDelete: { region: string; id: string; name: string }[] = [];

  for (const region of await listRegions()) {
    const ec2 = new EC2Client({ region });
    try {
      const { KeyPairs = [] } = await ec2.send(new DescribeKeyPairsCommand({}));
      if (!KeyPairs.length) continue;
      const uses = await keyReferences(ec2, new AutoScalingClient({ region }));
      for (const k of KeyPairs) {
        const name = k.KeyName ?? "?";
        const age = k.CreateTime ? Math.floor((Date.now() - k.CreateTime.getTime()) / 86_400_000) : -1;
        const refs = [...new Set(uses.get(name) ?? [])];
        const onlyTemplates = refs.length > 0 && refs.every((u) => u.startsWith("template") || u.startsWith("launch config"));
        const verdict = !refs.length ? (age >= minAgeDays ? "UNUSED" : "UNUSED (new)") : onlyTemplates ? "TEMPLATE ONLY" : "IN USE";
        if (verdict === "UNUSED" && k.KeyPairId) toDelete.push({ region, id: k.KeyPairId, name });
        rows.push({
          Region: region,
          KeyName: name,
          Type: k.KeyType ?? "?",
          AgeDays: age,
          Verdict: verdict,
          UsedBy: refs.slice(0, 2).join(", ") + (refs.length > 2 ? ` +${refs.length - 2} more` : ""),
        });
      }
    } catch (err) {
      rows.push({ Region: region, KeyName: `ERROR ${err instanceof Error ? err.name : err}`, Type: "", AgeDays: 0, Verdict: "", UsedBy: "" });
    }
  }

  const order = ["UNUSED", "UNUSED (new)", "TEMPLATE ONLY", "IN USE"];
  rows.sort((a, b) => order.indexOf(a.Verdict) - order.indexOf(b.Verdict) || b.AgeDays - a.AgeDays);
  console.table(rows);
  console.log(`${toDelete.length} key pair(s) unused and at least ${minAgeDays} days old.`);

  if (apply) {
    for (const k of toDelete) {
      await new EC2Client({ region: k.region }).send(new DeleteKeyPairCommand({ KeyPairId: k.id }));
      console.log(`Deleted ${k.name} (${k.id}) in ${k.region}`);
    }
  } else if (toDelete.length) {
    console.log("Run again with --apply to delete them from EC2.");
    process.exitCode = 2;
  }
}

main().catch((err) => {
  console.error(err instanceof Error ? `${err.name}: ${err.message}` : err);
  process.exit(1);
});

How do you run it?

Terminal

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

# Report every enabled Region
AWS_PROFILE=audit npx tsx find-unused-ec2-key-pairs.ts

# Delete unused key pairs older than 90 days in two Regions
AWS_PROFILE=ops npx tsx find-unused-ec2-key-pairs.ts --regions=us-east-1,eu-west-1 --min-age-days=90 --apply

Run the report first and read it. --apply deletes every row marked UNUSED, and a deleted key pair can’t be restored; you’d have to import the public key again from your private key.

Sample output

Output

┌─────────┬─────────────┬─────────────────┬───────────┬─────────┬─────────────────┬───────────────────────────────────────────────────────────────┐
│ (index) │ Region      │ KeyName         │ Type      │ AgeDays │ Verdict         │ UsedBy                                                        │
├─────────┼─────────────┼─────────────────┼───────────┼─────────┼─────────────────┼───────────────────────────────────────────────────────────────┤
│ 0       │ 'us-east-1' │ 'jenkins-2019'  │ 'rsa'     │ 2411    │ 'UNUSED'        │ ''                                                            │
│ 1       │ 'eu-west-1' │ 'alice-laptop'  │ 'rsa'     │ 812     │ 'UNUSED'        │ ''                                                            │
│ 2       │ 'us-east-1' │ 'temp-debug'    │ 'ed25519' │ 4       │ 'UNUSED (new)'  │ ''                                                            │
│ 3       │ 'us-east-1' │ 'asg-web'       │ 'ed25519' │ 301     │ 'TEMPLATE ONLY' │ 'template web-lt v7'                                          │
│ 4       │ 'us-east-1' │ 'bastion'       │ 'ed25519' │ 530     │ 'IN USE'        │ 'i-0a1b2c3d4e5f60718 (running)'                               │
│ 5       │ 'eu-west-1' │ 'batch-workers' │ 'rsa'     │ 198     │ 'IN USE'        │ 'i-0f9e8d7c6b5a40312 (stopped), template batch-lt v3 +1 more' │
└─────────┴─────────────┴─────────────────┴───────────┴─────────┴─────────────────┴───────────────────────────────────────────────────────────────┘
2 key pair(s) unused and at least 30 days old.
Run again with --apply to delete them from EC2.

The names are illustrative. jenkins-2019 and alice-laptop are the classic cleanup targets. asg-web is only referenced by a launch template, so the script keeps it: deleting it would make the next scale-out fail. temp-debug is unused but only 4 days old, so it waits for the age threshold.

What should you check before deleting a key pair?

  • Templates outside the latest and default versions. An Auto Scaling group or EC2 Fleet can pin a specific version number. If yours do, check LaunchTemplate.Version on your groups before deleting a TEMPLATE ONLY or UNUSED key.
  • Infrastructure as code. A Terraform, CDK or CloudFormation template that sets KeyName will fail on its next deployment. Search your repositories for the key name.
  • Other Regions. Key pairs are regional. The same key name in two Regions is two key pairs, and the script grades each separately.
  • Who holds the private key. For an IN USE key that belonged to someone who left, deleting it in EC2 changes nothing on the instance. Remove the line from authorized_keys on each server, or replace the key with a new one.

Better still, stop needing SSH keys. Session Manager connects through the SSM agent without inbound ports or key files; the script to find EC2 instances not managed by Systems Manager shows which servers can’t use it yet. Pair this cleanup with the scripts to find security groups with SSH open to the internet and to find EC2 instances with a public IP address, since an open port 22 is what makes a leaked key dangerous. The script to find long-stopped EC2 instances often turns the last users of an old key into candidates for removal too.

Troubleshooting

  • A key pair shows IN USE by a terminated-looking instance. Instances in shutting-down still count. Run the report again later.
  • UnauthorizedOperation on --apply. EC2 reports a denied action with this code rather than AccessDenied. The profile lacks ec2:DeleteKeyPair, or an SCP blocks it.
  • An Auto Scaling group’s activity history shows failed launches that mention the key pair. A pinned template version referenced a deleted key. Create a new template version without KeyName or with a current key, and point the group at it.
  • You can’t SSH to an instance and suspect the key. Deleting the EC2 key pair isn’t the cause. Follow the step-by-step guide to troubleshoot EC2 SSH connection problems.

Ask ChatWithCloud instead

ChatWithCloud turns a plain-English question into AWS SDK for JavaScript v2 code, runs it on your machine with your AWS profile, and sends the JSON result to the AI model to write the answer. Ask “Which key pairs in this Region aren’t used by any instance?” and it can compare DescribeKeyPairs with DescribeInstances. It checks one Region per session, and it may not think of launch templates unless you ask. Because it runs generated code without a confirmation step, it could also delete a key pair if you ask it to, so connect ChatWithCloud with a read-only AWS profile for questions like this and read the ChatWithCloud security page for what leaves your machine.

Frequently asked questions

What happens to running instances if I delete their EC2 key pair?

Nothing. The public key stays in authorized_keys on the instance, and you can still connect with the private key. Only new launches that name the deleted key fail.

How do I see which EC2 instances use a key pair?

Filter DescribeInstances by key-name, for example aws ec2 describe-instances --filters Name=key-name,Values=my-key. The script does the reverse lookup for every key at once.

Can I recover a deleted EC2 key pair?

Not from AWS. If you still have the private key, derive the public key with ssh-keygen -y -f key.pem and import it again with ImportKeyPair under the same name.

Do unused EC2 key pairs cost money?

No. Key pairs are free; the reason to remove them is a shorter, auditable list of credentials that can launch instances.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud