Find Unused IAM Roles With RoleLastUsed

To find unused IAM roles, list roles with ListRoles, then call GetRole for each one and read RoleLastUsed.LastUsedDate, because ListRoles doesn’t return it. Skip service-linked roles under /aws-service-role/. A missing date means no use within IAM’s tracking period of up to 400 days, not “never used”.

Roles pile up: every CloudFormation stack, proof of concept and vendor integration leaves one behind, often with broad permissions and a trust policy nobody remembers. To see which of them hold full admin, run the script to find IAM policies that grant admin access alongside this one. This example is for engineers who want to find unused IAM roles before an access review, with enough context per role to decide what to do. The script uses AWS SDK for JavaScript v3 and is report-only: it never changes or deletes a role.

It completes the identity part of an account review alongside the examples to find IAM users without MFA and to find IAM access keys older than 90 days or never used. All three are in our AWS SDK v3 practical examples.

What does RoleLastUsed tell you?

RoleLastUsed holds two fields: LastUsedDate and the Region where the role was last used. It’s the same data the IAM console shows as Last activity. Three details decide how you read it:

  • Only GetRole returns it. ListRoles leaves out RoleLastUsed, PermissionsBoundary and Tags, so the script makes one extra call per role.
  • The window is the trailing 400 days. A role with no LastUsedDate hasn’t been used in that period, or in a shorter one if your Region started tracking within the last year. It might have been used before that.
  • It covers any use of the role. The date reflects the last attempt to access any AWS service with the role, which can differ from the per-service Last Accessed tab.

Removing roles you don’t use is ordinary least-privilege hygiene. NIST lists disabling accounts that have been inactive for a defined period as control AC-2(3) in NIST SP 800-53 Rev. 5, Security and Privacy Controls; an unused role with an open trust policy is the cloud version of that inactive account.

Which roles does the script skip?

Role type Path Why it’s skipped
Service-linked roles /aws-service-role/ Owned by an AWS service; how to delete them depends on that service, and some are removed automatically with the resource
IAM Identity Center roles /aws-reserved/sso.amazonaws.com/ Named AWSReservedSSO_* and managed by permission sets; you can’t modify them in IAM

Everything else is reported if its last use, or its creation date when there is no last use, is older than --days (default 90). Roles younger than the threshold are never flagged, so a role created yesterday for a deploy that hasn’t run yet stays off the list.

Prerequisites

  • Node.js 20 or later, npm and tsx.
  • The @aws-sdk/client-iam package.
  • A profile in the account you’re auditing. IAM is global; the script signs requests for us-east-1 unless AWS_REGION is set.

Which IAM permissions does it need?

Two read actions and nothing else, because the script never writes. Replace 123456789012 with your account ID.

find-unused-iam-roles-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListAllRoles",
      "Effect": "Allow",
      "Action": "iam:ListRoles",
      "Resource": "*"
    },
    {
      "Sid": "ReadRoleLastUsed",
      "Effect": "Allow",
      "Action": "iam:GetRole",
      "Resource": "arn:aws:iam::123456789012:role/*"
    }
  ]
}

The AWS managed IAMReadOnlyAccess and SecurityAudit policies both include these. If you extend the script, generate a matching policy with the free IAM policy generator for TypeScript.

The script to find unused IAM roles

find-unused-iam-roles.ts

// find-unused-iam-roles.ts
// Reports IAM roles not used for --days days (default 90), including roles with no
// recorded use in IAM's tracking period. Skips service-linked roles and roles managed by
// IAM Identity Center. Report only: it never changes or deletes anything.
// Usage: npx tsx find-unused-iam-roles.ts [--days 90]
import { IAMClient, GetRoleCommand, paginateListRoles } from "@aws-sdk/client-iam";

const DAY = 86_400_000;
const SKIP_PATHS = ["/aws-service-role/", "/aws-reserved/"];

function numberArg(name: string, fallback: number): number {
  const i = process.argv.indexOf(name);
  const value = i > -1 ? Number(process.argv[i + 1]) : fallback;
  if (!Number.isFinite(value) || value < 1) throw new Error(`${name} must be a positive number`);
  return value;
}

const unusedDays = numberArg("--days", 90);

const iam = new IAMClient({
  region: process.env.AWS_REGION ?? "us-east-1",
  maxAttempts: 8,
  retryMode: "adaptive",
});

type Finding = {
  role: string;
  ageDays: number;
  lastUsed: string;
  lastRegion: string;
  idleDays: number | string;
  trustedBy: string;
};

// The trust policy comes back URL-encoded. Pull out the principals for context.
function trustedPrincipals(doc?: string): string {
  if (!doc) return "?";
  try {
    const policy = JSON.parse(decodeURIComponent(doc)) as {
      Statement?: { Principal?: string | Record<string, string | string[]> }[];
    };
    const out = new Set<string>();
    for (const s of policy.Statement ?? []) {
      const p = s.Principal;
      if (typeof p === "string") out.add(p);
      else for (const v of Object.values(p ?? {})) for (const x of [v].flat()) out.add(x);
    }
    return [...out].join(", ");
  } catch {
    return "?";
  }
}

async function main(): Promise<void> {
  const now = Date.now();
  const findings: Finding[] = [];
  let checked = 0;
  let skipped = 0;

  for await (const page of paginateListRoles({ client: iam }, {})) {
    for (const listed of page.Roles ?? []) {
      if (!listed.RoleName || !listed.CreateDate) continue;
      if (SKIP_PATHS.some((p) => listed.Path?.startsWith(p))) {
        skipped++;
        continue;
      }
      checked++;
      // ListRoles doesn't return RoleLastUsed; GetRole does.
      const { Role } = await iam.send(new GetRoleCommand({ RoleName: listed.RoleName }));
      const ageDays = Math.floor((now - listed.CreateDate.getTime()) / DAY);
      const last = Role?.RoleLastUsed?.LastUsedDate;
      const idle = last ? Math.floor((now - last.getTime()) / DAY) : undefined;

      const unused = idle === undefined ? ageDays > unusedDays : idle > unusedDays;
      if (!unused) continue;

      findings.push({
        role: listed.RoleName,
        ageDays,
        lastUsed: last ? last.toISOString().slice(0, 10) : "none in tracking period",
        lastRegion: Role?.RoleLastUsed?.Region ?? "-",
        idleDays: idle ?? "n/a",
        trustedBy: trustedPrincipals(listed.AssumeRolePolicyDocument),
      });
    }
  }

  findings.sort((a, b) => b.ageDays - a.ageDays);
  console.table(findings);
  console.log(
    `${checked} roles checked, ${skipped} service-linked or Identity Center roles skipped, ` +
      `${findings.length} not used in the last ${unusedDays} days.`,
  );
}

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

The trustedBy column comes from the role’s trust policy, which ListRoles returns URL-encoded. It tells you who could assume the role: a service such as lambda.amazonaws.com, another account, or a federated identity provider. That’s usually the fastest clue to what created it. For roles that are still in use, the script to find IAM roles trusted by external AWS accounts runs a fuller trust check on every role. The client uses retryMode: "adaptive" and 8 attempts because IAM throttles bursts of GetRole calls; the guide to configure retry and timeout settings in AWS SDK for JavaScript v3 explains both options.

How do you run it?

Terminal

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

# Roles unused for 90 days (default)
AWS_PROFILE=security-audit npx tsx find-unused-iam-roles.ts

# Stricter: unused for 30 days
AWS_PROFILE=security-audit npx tsx find-unused-iam-roles.ts --days 30

Sample output

Output (illustrative)

┌─────────┬────────────────────────────┬─────────┬───────────────────────────┬─────────────┬──────────┬──────────────────────────────────┐
│ (index) │ role                       │ ageDays │ lastUsed                  │ lastRegion  │ idleDays │ trustedBy                        │
├─────────┼────────────────────────────┼─────────┼───────────────────────────┼─────────────┼──────────┼──────────────────────────────────┤
│ 0       │ 'vendor-monitoring-legacy' │ 1204    │ 'none in tracking period' │ '-'         │ 'n/a'    │ 'arn:aws:iam::999988887777:root' │
│ 1       │ 'poc-etl-glue-role'        │ 640     │ '2025-10-14'              │ 'us-east-1' │ 348      │ 'glue.amazonaws.com'             │
│ 2       │ 'old-deploy-lambda-role'   │ 212     │ '2026-05-30'              │ 'eu-west-1' │ 120      │ 'lambda.amazonaws.com'           │
└─────────┴────────────────────────────┴─────────┴───────────────────────────┴─────────────┴──────────┴──────────────────────────────────┘
148 roles checked, 37 service-linked or Identity Center roles skipped, 3 not used in the last 90 days.

Names and account IDs are placeholders. The first row is the most urgent: a role another account can assume, with no recorded use in the tracking period. The last is the least: a Lambda execution role idle for 120 days might belong to a function that only runs quarterly. Before acting, check whether a resource still references the role; the example to delete old and unused Lambda function versions is a good companion for the Lambda side. RoleLastUsed says when, not what; the calls themselves are in CloudTrail, as long as the script to check CloudTrail is enabled and logging in every Region comes back clean.

How do you remove an unused role safely?

The script stops at the report on purpose. Deleting a role that a running instance or a yearly job depends on breaks it, and a deleted role can’t be restored with the same ID. Work through the list by hand:

  1. Find the ownerUse trustedBy, tags and the role’s creation date. When a CloudFormation template doesn’t set a role name, the generated name starts with the stack name; delete those roles through the stack.
  2. Disable before deletingAWS suggests this if you’re unsure: attach a deny-all policy or edit the trust policy so nobody can assume the role, and revoke active sessions. Undoing it is one policy change.
  3. Wait out a business cycleGive it at least a month, longer for roles that might run quarterly jobs, and watch for access-denied errors naming the role.
  4. Detach and deleteThrough the API you must first remove the role from instance profiles, delete inline policies and detach managed policies, then call DeleteRole. The console does those steps for you.

If something fails while a role is disabled, the steps to troubleshoot an AWS IAM access denied error will point straight at the deny policy you added.

Troubleshooting

  • Throttling: Rate exceeded. Accounts with hundreds of roles hit IAM rate limits. The adaptive retry mode slows down automatically; if it still fails, rerun later or raise maxAttempts.
  • A role you know is in use shows no date. Recent activity can take a few hours to appear; the IAM documentation says up to four hours for the console’s last accessed data. Rerun the next day before acting.
  • AccessDenied on GetRole for some roles. A permissions boundary or SCP may restrict reads on certain paths. You can check the permissions of your current IAM role to confirm what the audit role can see.
  • Roles for AWS Organizations or Control Tower appear. Roles such as OrganizationAccountAccessRole may be unused for months by design. Keep a short allow-list and filter them out.

Ask ChatWithCloud instead

You can ask ChatWithCloud “Which IAM roles haven’t been used in 90 days, and who can assume them?” from a read-only profile. It writes AWS SDK for JavaScript v2 code, runs it locally with your AWS profile and summarizes the findings; how ChatWithCloud runs AWS SDK code on your machine shows each step. It runs any change it writes without a confirmation step, so keep role deletion out of the profile you use for questions. For a broader review in plain English, see how to analyze your AWS security posture with an AI CLI.

Frequently asked questions

How do I find unused IAM roles with the AWS CLI?

Run aws iam list-roles to get role names, then aws iam get-role --role-name NAME for each and read RoleLastUsed. The console’s Last activity column shows the same data.

How far back does RoleLastUsed go?

The trailing 400 days, or less if your Region started tracking within the last year.

Can I delete a service-linked role?

Usually through the service that owns it, and sometimes the service deletes it for you when you remove its resources. Check that service’s documentation first.

Should I delete unused roles or disable them first?

Disable first with a deny policy or a locked trust policy, wait, then delete. Disabling is reversible; deletion isn’t.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud