Photo by Ilya Semenov on Unsplash
An unused IAM policies cleanup starts with ListPolicies using Scope: Local, which returns your customer managed policies with AttachmentCount and PermissionsBoundaryUsageCount. A policy with both at 0 isn’t attached to any user, group or role. To delete one, save its versions, delete the non-default versions with DeletePolicyVersion, then call DeletePolicy.
Customer managed policies pile up: one per experiment, per retired service, per old pipeline. Nobody deletes them because nobody is sure they’re unused. This example is for engineers who own an AWS account’s IAM hygiene and want an unused IAM policies cleanup that’s backed by data instead of guesswork.
The TypeScript script for the AWS SDK for JavaScript v3 lists every unattached customer managed policy with its age and version count, shows how close you are to the account quota, and flags attached policies stuck at the 5-version limit. With --names and --apply it re-checks each named policy, writes every version to a JSON backup and deletes it. Detecting policies that grant admin rights is a separate job: the example to find IAM policies that grant admin access covers it, including unattached ones.
What makes a customer managed policy unused?
IAM tracks two counters on every managed policy:
AttachmentCountis the number of users, groups and roles the policy is attached to.PermissionsBoundaryUsageCountis the number of users and roles that use it as a permissions boundary. A boundary policy has anAttachmentCountof 0 and still matters, so the script requires both counters to be 0.
Unattached isn’t the same as safe to delete. A policy can be unattached today and needed tomorrow, for example a break-glass policy that a runbook attaches during an incident. The script therefore also shows when the policy was last changed and skips anything changed in the last 90 days by default.
There are two practical reasons to clean up. The IAM quotas page lists a default of 1,500 customer managed policies per account, adjustable to 10,000. And an unattached policy is one AttachRolePolicy call away from granting whatever it contains, which is why least privilege guidance such as the NIST definition of least privilege is about the permissions that exist, not only the ones in use today.
Why do old policy versions matter?
A managed policy can have up to five versions. When a policy is at five, the next CreatePolicyVersion call fails until you delete one, so a deployment that updates the policy breaks. Versions also block deletion: the DeletePolicy API reference says you must detach the policy and delete every non-default version first, and DeletePolicy itself removes the default version. The script handles that order for you and reports attached policies already at the limit.
What does the script do?
- Reads the account summary
GetAccountSummaryreturnsPoliciesandPoliciesQuota, so you know how close the account is to the limit. - Lists customer managed policies
paginateListPolicieswithScope: LocalandOnlyAttached: false, so AWS managed policies are skipped and unattached ones are included. - Counts versions
paginateListPolicyVersionsfor each policy, used both for the report and for the delete order. - Deletes only what you nameWith
--apply, for each name in--namesit callsGetPolicyagain, stops if either counter is above 0, saves every version withGetPolicyVersion(URL-decoded), deletes non-default versions and then callsDeletePolicy.
Prerequisites
- Node.js 18 or later, npm,
tsxand@aws-sdk/client-iam. - A read-only profile for the report. For the delete step, a separate IAM admin profile; the guide to assume an IAM role with AWS SDK v3 shows how to switch to it.
- Know which policies your infrastructure code owns. Deleting a CloudFormation or Terraform managed policy outside the code creates drift.
Which IAM permissions does it need?
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadPolicies",
"Effect": "Allow",
"Action": [
"iam:GetAccountSummary",
"iam:ListPolicies",
"iam:ListPolicyVersions",
"iam:GetPolicy",
"iam:GetPolicyVersion"
],
"Resource": "*"
},
{
"Sid": "DeleteWithApply",
"Effect": "Allow",
"Action": [
"iam:DeletePolicyVersion",
"iam:DeletePolicy"
],
"Resource": "arn:aws:iam::123456789012:policy/*"
}
]
}
Drop the second statement for an audit-only role. Before you widen any policy, the guide to review a generated IAM policy for least privilege is a useful checklist.
The script to find unattached IAM policies
// find-unattached-iam-policies.ts
// Lists customer managed IAM policies that aren't attached to any user, group or role and aren't used
// as a permissions boundary, plus attached policies at the 5-version limit. Report only unless you pass
// --apply with --names: then it backs up every version, deletes the non-default versions and deletes the policy.
// Usage:
// npx tsx find-unattached-iam-policies.ts [--min-age-days 90] [--backup-dir ./iam-policy-backup]
// npx tsx find-unattached-iam-policies.ts --names old-deploy-policy,legacy-s3-read --apply
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import {
DeletePolicyCommand,
DeletePolicyVersionCommand,
GetAccountSummaryCommand,
GetPolicyCommand,
GetPolicyVersionCommand,
IAMClient,
paginateListPolicies,
paginateListPolicyVersions,
type Policy,
} from "@aws-sdk/client-iam";
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 minAgeDays = Number(flag("--min-age-days") ?? "90");
const backupDir = flag("--backup-dir") ?? "./iam-policy-backup";
const names = new Set((flag("--names") ?? "").split(",").map((s) => s.trim()).filter(Boolean));
const apply = args.includes("--apply");
const iam = new IAMClient({ region: process.env.AWS_REGION ?? "us-east-1" });
interface Row {
Policy: string;
Path: string;
Created: string;
LastChanged: string;
Versions: number;
AgeDays: number;
Status: string;
}
const day = (d: Date | undefined) => (d ? d.toISOString().slice(0, 10) : "?");
const daysSince = (d: Date | undefined) => (d ? Math.floor((Date.now() - d.getTime()) / 86_400_000) : 0);
async function versionIds(arn: string): Promise<{ id: string; isDefault: boolean }[]> {
const out: { id: string; isDefault: boolean }[] = [];
for await (const page of paginateListPolicyVersions({ client: iam }, { PolicyArn: arn })) {
for (const v of page.Versions ?? []) if (v.VersionId) out.push({ id: v.VersionId, isDefault: v.IsDefaultVersion === true });
}
return out;
}
// Saves every version's document (URL-decoded) so a deleted policy can be recreated.
async function backup(p: Policy, versions: { id: string; isDefault: boolean }[]): Promise<string> {
const docs = [];
for (const v of versions) {
const res = await iam.send(new GetPolicyVersionCommand({ PolicyArn: p.Arn, VersionId: v.id }));
docs.push({ versionId: v.id, isDefault: v.isDefault, document: JSON.parse(decodeURIComponent(res.PolicyVersion?.Document ?? "{}")) });
}
await mkdir(backupDir, { recursive: true });
const file = join(backupDir, `${p.PolicyName}.json`);
await writeFile(file, JSON.stringify({ arn: p.Arn, path: p.Path, versions: docs }, null, 2));
return file;
}
async function remove(p: Policy, versions: { id: string; isDefault: boolean }[]): Promise<string> {
// Re-read the counts right before deleting: something may have attached it since the scan.
const { Policy: fresh } = await iam.send(new GetPolicyCommand({ PolicyArn: p.Arn }));
if ((fresh?.AttachmentCount ?? 0) > 0 || (fresh?.PermissionsBoundaryUsageCount ?? 0) > 0) return "skipped (now in use)";
const file = await backup(p, versions);
for (const v of versions) {
if (!v.isDefault) await iam.send(new DeletePolicyVersionCommand({ PolicyArn: p.Arn, VersionId: v.id }));
}
await iam.send(new DeletePolicyCommand({ PolicyArn: p.Arn })); // also deletes the default version
return `deleted (backup ${file})`;
}
async function main(): Promise<void> {
if (apply && names.size === 0) {
console.error("--apply needs --names with the policy names to delete.");
process.exit(1);
}
const { SummaryMap: s = {} } = await iam.send(new GetAccountSummaryCommand({}));
console.log(`Customer managed policies: ${s.Policies ?? "?"} of ${s.PoliciesQuota ?? "?"} allowed`);
const unattached: Row[] = [];
const atLimit: string[] = [];
const policies = new Map<string, { p: Policy; versions: { id: string; isDefault: boolean }[] }>();
for await (const page of paginateListPolicies({ client: iam }, { Scope: "Local", OnlyAttached: false })) {
for (const p of page.Policies ?? []) {
if (!p.Arn || !p.PolicyName) continue;
const versions = await versionIds(p.Arn);
const inUse = (p.AttachmentCount ?? 0) > 0 || (p.PermissionsBoundaryUsageCount ?? 0) > 0;
if (inUse) {
if (versions.length >= 5) atLimit.push(`${p.PolicyName} (${p.AttachmentCount} attachments)`);
continue;
}
const age = daysSince(p.UpdateDate ?? p.CreateDate);
policies.set(p.PolicyName, { p, versions });
unattached.push({
Policy: p.PolicyName,
Path: p.Path ?? "/",
Created: day(p.CreateDate),
LastChanged: day(p.UpdateDate),
Versions: versions.length,
AgeDays: age,
Status: age >= minAgeDays ? "cleanup candidate" : `changed in the last ${minAgeDays} days`,
});
}
}
if (apply) {
for (const row of unattached) {
if (!names.has(row.Policy)) continue;
const item = policies.get(row.Policy);
if (!item) continue;
try {
row.Status = await remove(item.p, item.versions);
} catch (err) {
row.Status = `error: ${err instanceof Error ? err.name : String(err)}`;
}
}
}
unattached.sort((a, b) => b.AgeDays - a.AgeDays);
console.log(`Unattached customer managed policies: ${unattached.length}`);
if (unattached.length) console.table(unattached);
console.log(`Attached policies at the 5-version limit: ${atLimit.length}`);
for (const line of atLimit) console.log(` ${line}`);
if (!apply) console.log("Report only. Pass --names and --apply to back up and delete named policies.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-iam
npm install --save-dev tsx typescript @types/node
# Report only
AWS_PROFILE=readonly npx tsx find-unattached-iam-policies.ts
# Back up and delete one policy you've confirmed nobody needs
AWS_PROFILE=iam-admin npx tsx find-unattached-iam-policies.ts --names legacy-s3-read --apply
Sample output
Customer managed policies: 214 of 1500 allowed
Unattached customer managed policies: 4
┌─────────┬───────────────────┬────────┬──────────────┬──────────────┬──────────┬─────────┬──────────────────────────────────────────────────────────┐
│ (index) │ Policy │ Path │ Created │ LastChanged │ Versions │ AgeDays │ Status │
├─────────┼───────────────────┼────────┼──────────────┼──────────────┼──────────┼─────────┼──────────────────────────────────────────────────────────┤
│ 0 │ 'legacy-s3-read' │ '/' │ '2024-03-11' │ '2024-06-02' │ 3 │ 1195 │ 'deleted (backup iam-policy-backup/legacy-s3-read.json)' │
│ 1 │ 'ci-deploy-old' │ '/ci/' │ '2025-01-20' │ '2025-01-20' │ 1 │ 962 │ 'cleanup candidate' │
│ 2 │ 'AppTeamReadOnly' │ '/' │ '2025-11-04' │ '2026-02-17' │ 2 │ 569 │ 'cleanup candidate' │
│ 3 │ 'orders-api-v2' │ '/' │ '2027-08-30' │ '2027-08-30' │ 1 │ 10 │ 'changed in the last 90 days' │
└─────────┴───────────────────┴────────┴──────────────┴──────────────┴──────────┴─────────┴──────────────────────────────────────────────────────────┘
Attached policies at the 5-version limit: 1
deploy-pipeline (3 attachments)
Names and dates are illustrative. legacy-s3-read was deleted after its three versions were saved. orders-api-v2 is new and probably waiting for a deploy, so it’s held back. deploy-pipeline is attached but at five versions, so its next update will fail until an old version is removed.
How do you run an unused IAM policies cleanup safely?
- Check who owns itSearch your CloudFormation, CDK and Terraform code for the policy name. If code created it, delete it there; the script to detect CloudFormation drift across all stacks shows what an out-of-band delete leaves behind.
- Check IAM Identity CenterA permission set that uses a customer managed policy needs a policy with the same name and path in every account it’s assigned to, created before the assignment. A policy staged for that looks unattached until the permission set is provisioned.
- Check runbooksBreak-glass and incident policies are attached on demand. Tag them so they’re easy to exclude.
- Delete in small batches with backupsThe JSON backup holds every version’s document, so you can recreate a policy with
CreatePolicyandCreatePolicyVersionif something breaks.
Unattached policies are one part of IAM hygiene. The same review usually covers roles and users: the scripts to find unused IAM roles with RoleLastUsed, find IAM users with directly attached policies and find IAM access keys older than 90 days or never used fit into the same pass.
Troubleshooting
DeleteConflictException. The SDK describes it as an attempt to delete a resource that has attached subordinate entities. Something attached the policy after the scan; run the report again.NoSuchEntityException. The policy was deleted between the scan and the delete. Re-run the report.AccessDeniedon delete. A service control policy or permissions boundary may block IAM changes; the guide to troubleshoot AWS IAM access denied errors step by step helps find which one.- The backup file has an empty document.
GetPolicyVersionreturns the document URL-encoded; the script decodes it withdecodeURIComponent. If you adapt it, keep that step.
Ask ChatWithCloud instead
Ask ChatWithCloud “Which customer managed IAM policies aren’t attached to anything?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and summarizes the list. It runs generated code without a confirmation step, so don’t ask it to delete policies from an admin profile; keep it on a read-only one. The ChatWithCloud security model explains what runs on your machine and what’s sent for processing. For a broader review, the guide to analyze your AWS security posture with an AI CLI shows the questions to ask.
Frequently asked questions
How do I find unattached IAM policies?
Run aws iam list-policies --scope Local --query "Policies[?AttachmentCount==`0` && PermissionsBoundaryUsageCount==`0`].PolicyName". That lists customer managed policies with no attachments that aren’t used as a permissions boundary.
Why can’t I delete an IAM policy?
It’s still attached, or it has non-default versions. Detach it from every user, group and role, delete the non-default versions with DeletePolicyVersion, then call DeletePolicy.
How many versions can an IAM managed policy have?
Five. When a policy has five, you have to delete one before you can create another.
Is it safe to delete unused IAM policies?
Usually, after you check infrastructure code, IAM Identity Center permission sets and runbooks that attach policies on demand. Back up every version first so you can recreate the policy.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud