Find IAM Users With Directly Attached Policies

A tangle of blue and yellow network cables plugged into a patch panel

Photo by Steve A Johnson on Unsplash

To find IAM users with directly attached policies, page through ListUsers and call ListAttachedUserPolicies (managed policies) and ListUserPolicies (inline policies) for each user. Any user with a result from either call gets permissions outside a group or role. ListGroupsForUser shows which groups the user already belongs to, so you can move the policy there.

IAM users pick up permissions three ways: managed policies attached to the user, inline policies embedded in the user, and policies on the groups they belong to. The first two are easy to add during an incident and hard to track afterwards, because every user becomes a one-off. This example is for engineers who need to find IAM users with directly attached policies across an account and move those permissions to groups or roles.

The script is read-only TypeScript for the AWS SDK for JavaScript v3. It answers a different question from the script to find IAM policies that grant admin access: that one checks what a policy allows, this one checks how a policy reaches a user.

Why attach policies to groups instead of users?

AWS Security Hub control IAM.2, “IAM users should not have IAM policies attached”, fails for any user with a policy attached directly, and says users should inherit permissions from IAM groups or assume a role instead. The reason is management at scale: with 40 users and 40 slightly different policy sets, nobody can say who can do what, and users keep permissions long after they change teams. The CIS Amazon Web Services Foundations Benchmark has the same recommendation; as mapped in Security Hub, it’s 1.14 in v5.0.0 and 1.15 in v3.0.0.

Groups also make least privilege, as NIST defines it, easier to review: you check one group policy instead of every user. Keep in mind that groups aren’t principals. You can’t name a group in a resource policy or trust policy, and groups can’t contain other groups.

Managed and inline policies aren’t the same risk

Direct attachment Script verdict Why
AdministratorAccess, PowerUserAccess or IAMFullAccess HIGH Near-total access that no group review will catch.
Any inline policy MEDIUM Doesn’t appear in the policy list, can’t be reused, and is deleted with the user.
Other managed policies LOW Visible and reusable, but still per-user.

IAM limits both. A user can have 10 managed policies attached by default (you can raise it to 20), and all inline policies on a user together can’t exceed 2,048 characters, not counting white space. Hitting either limit is often the first sign a user has been patched too many times.

What does the script do?

  1. Lists userspaginateListUsers, optionally under a path such as /engineering/.
  2. Reads direct policiespaginateListAttachedUserPolicies for managed policies and paginateListUserPolicies for inline policy names.
  3. Reads group membershippaginateListGroupsForUser, so you can see whether a direct policy duplicates a group.
  4. Suggests groupsUsers with an identical set of managed policies are printed together; each set is a candidate IAM group.
  5. Reports and exitsA table sorted by severity and exit code 2 when any user has direct policies.

Prerequisites

Which IAM permissions does it need?

Replace the account ID. ListUsers is granted on *; the per-user calls are limited to user ARNs.

direct-user-policies-audit.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListUsers",
      "Effect": "Allow",
      "Action": "iam:ListUsers",
      "Resource": "*"
    },
    {
      "Sid": "ReadUserPoliciesAndGroups",
      "Effect": "Allow",
      "Action": [
        "iam:ListAttachedUserPolicies",
        "iam:ListUserPolicies",
        "iam:ListGroupsForUser"
      ],
      "Resource": "arn:aws:iam::111122223333:user/*"
    }
  ]
}

To write a policy like this from your own SDK code, the free IAM policy generator for TypeScript code reads the commands you use and drafts the actions.

The script to find IAM users with directly attached policies

find-iam-users-with-directly-attached-policies.ts

// find-iam-users-with-directly-attached-policies.ts
// Lists IAM users that get permissions from policies attached to the user itself (managed or inline)
// instead of through groups or roles, shows their group memberships, and groups users that share the
// same attached policies so you can see which IAM groups to create. Report-only: it changes nothing.
// Usage: npx tsx find-iam-users-with-directly-attached-policies.ts [--path=/engineering/] [--all]
import {
  IAMClient,
  paginateListUsers,
  paginateListAttachedUserPolicies,
  paginateListUserPolicies,
  paginateListGroupsForUser,
} from "@aws-sdk/client-iam";

const args = process.argv.slice(2);
const pathPrefix = args.find((a) => a.startsWith("--path="))?.split("=")[1] ?? "/";
const showAll = args.includes("--all"); // also list users with no direct policies

type Severity = "HIGH" | "MEDIUM" | "LOW" | "OK";
interface Row {
  User: string;
  Managed: string;
  Inline: string;
  Groups: string;
  LastSignIn: string;
  Severity: Severity;
}

// AWS managed policies that hand over most or all of the account.
const BROAD = new Set(["AdministratorAccess", "PowerUserAccess", "IAMFullAccess"]);
const iam = new IAMClient({});

async function collect<T>(pages: AsyncIterable<T>, pick: (page: T) => string[]): Promise<string[]> {
  const out: string[] = [];
  for await (const page of pages) out.push(...pick(page));
  return out;
}

async function main(): Promise<void> {
  const rows: Row[] = [];
  const bySet = new Map<string, string[]>(); // "policyA + policyB" -> users
  let users = 0;

  for await (const page of paginateListUsers({ client: iam }, { PathPrefix: pathPrefix })) {
    for (const user of page.Users ?? []) {
      const name = user.UserName;
      if (!name) continue;
      users++;
      const managed = await collect(paginateListAttachedUserPolicies({ client: iam }, { UserName: name }),
        (p) => (p.AttachedPolicies ?? []).map((a) => a.PolicyName ?? "?"));
      const inline = await collect(paginateListUserPolicies({ client: iam }, { UserName: name }), (p) => p.PolicyNames ?? []);
      const groups = await collect(paginateListGroupsForUser({ client: iam }, { UserName: name }),
        (p) => (p.Groups ?? []).map((g) => g.GroupName ?? "?"));

      const direct = managed.length + inline.length;
      if (!direct && !showAll) continue;
      const severity: Severity = !direct ? "OK" : managed.some((m) => BROAD.has(m)) ? "HIGH" : inline.length ? "MEDIUM" : "LOW";
      rows.push({
        User: name,
        Managed: managed.join(", ") || "-",
        Inline: inline.join(", ") || "-",
        Groups: groups.join(", ") || "(none)",
        LastSignIn: user.PasswordLastUsed ? user.PasswordLastUsed.toISOString().slice(0, 10) : "-",
        Severity: severity,
      });
      if (managed.length) {
        const key = [...managed].sort().join(" + ");
        bySet.set(key, [...(bySet.get(key) ?? []), name]);
      }
    }
  }

  const order: Record<Severity, number> = { HIGH: 0, MEDIUM: 1, LOW: 2, OK: 3 };
  rows.sort((a, b) => order[a.Severity] - order[b.Severity] || a.User.localeCompare(b.User));
  console.table(rows);

  const candidates = [...bySet.entries()].filter(([, names]) => names.length > 1);
  if (candidates.length) {
    console.log("Users sharing the same managed policies (candidates for one IAM group each):");
    for (const [policies, names] of candidates) console.log(`  ${policies}  <-  ${names.join(", ")}`);
  }
  const flagged = rows.filter((r) => r.Severity !== "OK").length;
  console.log(`${users} user(s) read under path ${pathPrefix}; ${flagged} with directly attached policies.`);
  if (flagged) 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-iam
npm install --save-dev tsx typescript

# Every user with a direct policy
AWS_PROFILE=security-audit npx tsx find-iam-users-with-directly-attached-policies.ts

# One path, and include users without direct policies
AWS_PROFILE=security-audit npx tsx find-iam-users-with-directly-attached-policies.ts --path=/engineering/ --all

The script makes three IAM calls per user, one user at a time. In large accounts, raise maxAttempts as shown in the guide to configure AWS SDK v3 retries and timeouts so throttled calls retry. GetAccountAuthorizationDetails with Filter: ["User"] returns the same data in fewer, larger calls if you’d rather parse one big response.

Sample output

Output

┌─────────┬────────────────┬───────────────────────────────────────────────────┬──────────────────┬──────────────┬──────────────┬──────────┐
│ (index) │ User           │ Managed                                           │ Inline           │ Groups       │ LastSignIn   │ Severity │
├─────────┼────────────────┼───────────────────────────────────────────────────┼──────────────────┼──────────────┼──────────────┼──────────┤
│ 0       │ 'ci-legacy'    │ 'AdministratorAccess'                             │ '-'              │ '(none)'     │ '-'          │ 'HIGH'   │
│ 1       │ 'maria.lopez'  │ '-'                                               │ 's3-reports-tmp' │ 'developers' │ '2026-09-25' │ 'MEDIUM' │
│ 2       │ 'backup-agent' │ 'AmazonS3ReadOnlyAccess, AWSBackupOperatorAccess' │ '-'              │ '(none)'     │ '-'          │ 'LOW'    │
│ 3       │ 'etl-runner'   │ 'AmazonS3ReadOnlyAccess, AWSBackupOperatorAccess' │ '-'              │ '(none)'     │ '-'          │ 'LOW'    │
│ 4       │ 'sam.chen'     │ 'ReadOnlyAccess'                                  │ '-'              │ 'developers' │ '2026-09-27' │ 'LOW'    │
└─────────┴────────────────┴───────────────────────────────────────────────────┴──────────────────┴──────────────┴──────────────┴──────────┘
Users sharing the same managed policies (candidates for one IAM group each):
  AWSBackupOperatorAccess + AmazonS3ReadOnlyAccess  <-  backup-agent, etl-runner
42 user(s) read under path /; 5 with directly attached policies.

The names are illustrative. ci-legacy is the urgent one: a CI user with administrator access and no group, which usually also means old access keys. The last lines show two service users with identical policies, a ready-made group.

How do you move a user’s policies to a group?

  1. Create or pick the groupaws iam create-group --group-name backup-operators, or reuse a group the user is already in.
  2. Attach the policy to the groupaws iam attach-group-policy with the same policy ARN. For an inline policy, turn it into a customer managed policy first so it’s reusable.
  3. Add the usersaws iam add-user-to-group for each user in the candidate set.
  4. Remove the direct attachmentaws iam detach-user-policy for managed policies or delete-user-policy for inline ones, then have the user test.

While you’re there, ask whether the user should exist at all. People are better served by IAM Identity Center, and workloads by roles; the scripts to find old and unused IAM access keys and find IAM users without MFA show which users carry the most risk. The root user never appears in ListUsers, so check the AWS root user for MFA and access keys separately. Before attaching anything to a group, the guide to review an IAM policy for least privilege helps trim it.

Troubleshooting

  • Throttling: Rate exceeded. IAM rate-limits control plane calls. Raise the SDK’s retry attempts or run with --path to split the account.
  • NoSuchEntity for one user. The user was deleted while the script ran. Run it again.
  • A user shows no policies but still has access. Permissions come from groups, a permissions boundary doesn’t grant anything, and resource policies (S3, KMS) can grant access to a user directly. The script checks identity policies only.
  • A user gets AccessDenied after the move. The group policy may differ from the old inline one. Compare the two and check for an explicit Deny in another policy.

Ask ChatWithCloud instead

ChatWithCloud writes AWS SDK for JavaScript v2 code from a plain-English question, runs it locally with your profile and passes the JSON result to the AI model for the answer. “Which IAM users have policies attached directly instead of through groups?” leads to the same list calls, which is handy to find IAM users with directly attached policies during a review; the script suits scheduled checks better. Generated code runs without a confirmation step, so use a read-only AWS profile with ChatWithCloud, and read the ChatWithCloud security model first.

Frequently asked questions

How do I list policies attached to an IAM user with the CLI?

aws iam list-attached-user-policies --user-name NAME shows managed policies and aws iam list-user-policies --user-name NAME shows inline policy names. Neither shows policies inherited from groups.

Is it bad to attach policies directly to IAM users?

It works, but it doesn’t scale: each user becomes a special case. AWS Security Hub and the CIS benchmark both recommend granting permissions through groups or roles.

What’s the difference between inline and managed policies on a user?

A managed policy is a standalone object you can attach to many identities and version. An inline policy lives inside one user and is deleted with it.

Do permissions boundaries count as attached policies?

No. A boundary only limits what the user’s policies can grant, and ListAttachedUserPolicies doesn’t return it.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud