Photo by Kaffeebart on Unsplash
To find IAM policies with admin access, list your customer managed policies with ListPolicies (Scope: Local), read each default version with GetPolicyVersion, and flag Allow statements with Action: "*" on Resource: "*". Then check inline policies on users, groups and roles, and run ListEntitiesForPolicy on the AWS managed AdministratorAccess policy.
Admin access in AWS rarely comes from one obvious place. It’s an AdministratorAccess attachment on a CI role, a customer managed policy someone wrote as "Action": "*" to get past an error, an inline policy on a single user, or a NotAction statement that allows everything except a short list. Each one is a full account takeover if its credentials leak.
This example is for engineers who need to find IAM policies with admin access in one account and hand over a list of who holds them. You’ll get a read-only TypeScript script for the AWS SDK for JavaScript v3 that covers all three policy types. It never edits a policy; fixing them is a review task, and the guide to review a generated IAM policy for least privilege covers how to cut one down.
What counts as admin access in an IAM policy?
The script flags three statement shapes. All of them are "Effect": "Allow" on "Resource": "*" (or on a NotResource, which is just as broad):
| Finding | Statement shape | Why it matters |
|---|---|---|
| FULL ADMIN | "Action": "*" |
Every action on every resource. Identical to AdministratorAccess. |
| NEAR ADMIN | "NotAction": [...] |
Allows every action except those listed, including services launched after the policy was written. |
| PRIVILEGE ESCALATION | "Action": "iam:*" |
Can attach AdministratorAccess to itself, so it’s admin one API call later. |
A Condition block, such as requiring MFA or a source IP, is reported alongside the finding rather than hidden, because conditions narrow when access applies, not what it covers.
AWS Security Hub has a control for this, IAM.1, which Security Hub maps to CIS AWS Foundations Benchmark v1.4.0 recommendation 1.16. It checks only customer managed policies with Action "*" on Resource "*"; it doesn’t check inline or AWS managed policies. The script covers those gaps, which is where most surprises turn up.
What does the script do?
- Reads customer managed policies
ListPolicieswithScope: Local,OnlyAttached: trueandPolicyUsageFilter: PermissionsPolicy, thenGetPolicyVersionfor eachDefaultVersionId. Pass--include-unattachedto include policies nobody uses yet. - Reads inline policies
ListUserPolicies/GetUserPolicy, and the same pair for groups and roles. Service-linked roles are skipped, because AWS manages their permissions. - Finds AdministratorAccess holders
ListEntitiesForPolicyonarn:aws:iam::aws:policy/AdministratorAccess, limited to permissions-policy use so permissions boundaries don’t show up. - Prints one row per findingExit code 2 when there is at least one. Nothing is changed.
IAM returns policy documents URL-encoded (RFC 3986), and the JavaScript SDK hands them back that way, so the script decodes each one before parsing it. Skip that step and JSON.parse fails on the first %7B.
Prerequisites
- Node.js 18 or later, npm and
tsx, plus the@aws-sdk/client-iampackage. - A profile in the account you’re auditing, configured as in the guide to AWS SDK v3 credential providers and assume role.
Which IAM permissions does it need?
Only list and get actions, so it’s safe for an audit role. iam:GetPolicyVersion returns policy text, which is sensitive but not secret.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadIamPolicies",
"Effect": "Allow",
"Action": [
"iam:ListPolicies",
"iam:GetPolicyVersion",
"iam:ListEntitiesForPolicy",
"iam:ListUsers",
"iam:ListUserPolicies",
"iam:GetUserPolicy",
"iam:ListGroups",
"iam:ListGroupPolicies",
"iam:GetGroupPolicy",
"iam:ListRoles",
"iam:ListRolePolicies",
"iam:GetRolePolicy"
],
"Resource": "*"
}
]
}
If you’d rather derive it from code, the IAM policy generator for TypeScript AWS SDK code reads the commands the script imports. The guide to find the IAM actions your AWS SDK for JavaScript code needs explains the mapping.
The script to find IAM policies with admin access
// find-iam-policies-with-admin-access.ts
// Finds IAM policies that grant administrator-level access: customer managed policies (default version),
// inline policies on users, groups and roles, and attachments of the AWS managed AdministratorAccess policy.
// Flags Allow statements with Action "*" on Resource "*", NotAction on Resource "*", and "iam:*" on "*".
// Report-only: it never changes a policy.
// Usage: npx tsx find-iam-policies-with-admin-access.ts [--include-unattached]
import {
IAMClient,
GetGroupPolicyCommand,
GetPolicyVersionCommand,
GetRolePolicyCommand,
GetUserPolicyCommand,
paginateListEntitiesForPolicy,
paginateListGroupPolicies,
paginateListGroups,
paginateListPolicies,
paginateListRolePolicies,
paginateListRoles,
paginateListUserPolicies,
paginateListUsers,
} from "@aws-sdk/client-iam";
const iam = new IAMClient({ region: "us-east-1" }); // IAM is global in the aws partition; any commercial Region works
const includeUnattached = process.argv.includes("--include-unattached");
const ADMIN_ARN = "arn:aws:iam::aws:policy/AdministratorAccess";
interface Statement {
Effect?: string;
Action?: string | string[];
NotAction?: string | string[];
Resource?: string | string[];
NotResource?: string | string[];
Condition?: Record<string, unknown>;
}
interface Finding { Source: string; Policy: string; AttachedTo: string; Finding: string }
const list = (v: string | string[] | undefined): string[] => (v === undefined ? [] : Array.isArray(v) ? v : [v]);
// IAM returns policy documents URL-encoded (RFC 3986); the JavaScript SDK doesn't decode them for you.
function parseDocument(raw: string | undefined): Statement[] {
if (!raw) return [];
let text = raw;
try { text = decodeURIComponent(raw); } catch { /* already plain JSON */ }
const doc = JSON.parse(text) as { Statement?: Statement | Statement[] };
return doc.Statement === undefined ? [] : Array.isArray(doc.Statement) ? doc.Statement : [doc.Statement];
}
function classify(statements: Statement[]): string[] {
const out: string[] = [];
for (const s of statements) {
if (s.Effect !== "Allow") continue;
const onEverything = list(s.Resource).includes("*") || s.NotResource !== undefined;
if (!onEverything) continue;
const conditional = s.Condition ? " (with Condition)" : "";
const actions = list(s.Action).map((a) => a.toLowerCase());
if (actions.includes("*") || actions.includes("*:*")) out.push(`FULL ADMIN: Action * on Resource *${conditional}`);
else if (s.NotAction !== undefined) out.push(`NEAR ADMIN: NotAction [${list(s.NotAction).join(", ")}] on Resource *${conditional}`);
else if (actions.includes("iam:*")) out.push(`PRIVILEGE ESCALATION: iam:* on Resource *${conditional}`);
}
return out;
}
async function main(): Promise<void> {
const findings: Finding[] = [];
// 1. Customer managed policies used as permissions policies, default version only (the one in effect).
// Permissions boundaries only cap access, so a boundary with Action "*" is not a finding.
const policyFilter = { Scope: "Local", OnlyAttached: !includeUnattached, PolicyUsageFilter: "PermissionsPolicy" } as const;
for await (const page of paginateListPolicies({ client: iam }, policyFilter)) {
for (const p of page.Policies ?? []) {
if (!p.Arn || !p.DefaultVersionId) continue;
const v = await iam.send(new GetPolicyVersionCommand({ PolicyArn: p.Arn, VersionId: p.DefaultVersionId }));
for (const f of classify(parseDocument(v.PolicyVersion?.Document))) {
findings.push({ Source: "managed", Policy: p.PolicyName ?? p.Arn, AttachedTo: `${p.AttachmentCount ?? 0} attachment(s)`, Finding: f });
}
}
}
// 2. Inline policies embedded in users, groups and roles.
for await (const page of paginateListUsers({ client: iam }, {})) {
for (const u of page.Users ?? []) {
const name = u.UserName ?? "";
for await (const names of paginateListUserPolicies({ client: iam }, { UserName: name })) {
for (const pn of names.PolicyNames ?? []) {
const doc = await iam.send(new GetUserPolicyCommand({ UserName: name, PolicyName: pn }));
for (const f of classify(parseDocument(doc.PolicyDocument))) findings.push({ Source: "inline", Policy: pn, AttachedTo: `user/${name}`, Finding: f });
}
}
}
}
for await (const page of paginateListGroups({ client: iam }, {})) {
for (const g of page.Groups ?? []) {
const name = g.GroupName ?? "";
for await (const names of paginateListGroupPolicies({ client: iam }, { GroupName: name })) {
for (const pn of names.PolicyNames ?? []) {
const doc = await iam.send(new GetGroupPolicyCommand({ GroupName: name, PolicyName: pn }));
for (const f of classify(parseDocument(doc.PolicyDocument))) findings.push({ Source: "inline", Policy: pn, AttachedTo: `group/${name}`, Finding: f });
}
}
}
}
for await (const page of paginateListRoles({ client: iam }, {})) {
for (const r of page.Roles ?? []) {
if (r.Path?.startsWith("/aws-service-role/")) continue; // service-linked roles: permissions set by AWS
const name = r.RoleName ?? "";
for await (const names of paginateListRolePolicies({ client: iam }, { RoleName: name })) {
for (const pn of names.PolicyNames ?? []) {
const doc = await iam.send(new GetRolePolicyCommand({ RoleName: name, PolicyName: pn }));
for (const f of classify(parseDocument(doc.PolicyDocument))) findings.push({ Source: "inline", Policy: pn, AttachedTo: `role/${name}`, Finding: f });
}
}
}
}
// 3. Who has the AWS managed AdministratorAccess policy attached as a permissions policy.
const adminFilter = { PolicyArn: ADMIN_ARN, PolicyUsageFilter: "PermissionsPolicy" } as const;
for await (const page of paginateListEntitiesForPolicy({ client: iam }, adminFilter)) {
const who = [
...(page.PolicyUsers ?? []).map((u) => `user/${u.UserName}`),
...(page.PolicyGroups ?? []).map((g) => `group/${g.GroupName}`),
...(page.PolicyRoles ?? []).map((r) => `role/${r.RoleName}`),
];
for (const w of who) findings.push({ Source: "aws-managed", Policy: "AdministratorAccess", AttachedTo: w, Finding: "FULL ADMIN: AWS managed policy" });
}
console.table(findings);
const full = findings.filter((f) => f.Finding.startsWith("FULL ADMIN")).length;
console.log(`${findings.length} finding(s): ${full} full admin, ${findings.length - full} near-admin or escalation paths.`);
console.log("Report only: no policy was changed.");
if (findings.length) process.exitCode = 2;
}
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
AWS_PROFILE=security-audit npx tsx find-iam-policies-with-admin-access.ts
# Also scan customer managed policies that aren't attached to anything
AWS_PROFILE=security-audit npx tsx find-iam-policies-with-admin-access.ts --include-unattached
IAM is a global service, so there’s no Region loop. Accounts with thousands of roles make many calls; the SDK’s default retry handles Throttling errors, and the guide to configure retry and timeout settings in AWS SDK for JavaScript v3 shows how to raise maxAttempts if it still fails.
Sample output
┌─────────┬───────────────┬───────────────────────┬──────────────────────────────────────┬────────────────────────────────────────────────────────────────┐
│ (index) │ Source │ Policy │ AttachedTo │ Finding │
├─────────┼───────────────┼───────────────────────┼──────────────────────────────────────┼────────────────────────────────────────────────────────────────┤
│ 0 │ 'managed' │ 'ci-deploy' │ '2 attachment(s)' │ 'FULL ADMIN: Action * on Resource *' │
│ 1 │ 'managed' │ 'ops-break-glass' │ '1 attachment(s)' │ 'FULL ADMIN: Action * on Resource * (with Condition)' │
│ 2 │ 'managed' │ 'platform-team' │ '1 attachment(s)' │ 'NEAR ADMIN: NotAction [iam:*, organizations:*] on Resource *' │
│ 3 │ 'inline' │ 'bootstrap' │ 'role/legacy-jenkins' │ 'PRIVILEGE ESCALATION: iam:* on Resource *' │
│ 4 │ 'inline' │ 'temp-fix' │ 'user/alice' │ 'FULL ADMIN: Action * on Resource *' │
│ 5 │ 'aws-managed' │ 'AdministratorAccess' │ 'group/admins' │ 'FULL ADMIN: AWS managed policy' │
│ 6 │ 'aws-managed' │ 'AdministratorAccess' │ 'role/OrganizationAccountAccessRole' │ 'FULL ADMIN: AWS managed policy' │
└─────────┴───────────────┴───────────────────────┴──────────────────────────────────────┴────────────────────────────────────────────────────────────────┘
7 finding(s): 5 full admin, 2 near-admin or escalation paths.
Report only: no policy was changed.
Names are illustrative. OrganizationAccountAccessRole is expected in member accounts that AWS Organizations created: AWS creates that role with administrator permissions for the management account. The inline temp-fix policy on a single user is the classic leftover, and legacy-jenkins with iam:* is admin in all but name. The script to find IAM users with directly attached policies lists every user carrying one-off policies like that.
What should you do with each finding?
- Human users with full admin. Move people to roles they assume when needed, ideally through IAM Identity Center, and make sure the few who keep admin have MFA. The script to find IAM users without MFA is the natural next check.
- CI/CD and automation roles. Replace
*with the actions the pipeline actually calls. CloudTrail shows what it used; the script to check CloudTrail is enabled and logging in every AWS Region makes sure that record exists before you start. - Roles nobody uses. An unused admin role is pure risk. The script to find unused IAM roles with RoleLastUsed tells you which ones to delete rather than rewrite.
- Break-glass access. Keep one, deliberately: a role with admin, a strict trust policy, MFA and an alarm on use. Document it so the next audit can mark it as accepted. The script to audit IAM role trust policies for external accounts confirms no other account can assume an admin role.
Least privilege, in NIST’s glossary definition, means restricting access to the minimum necessary to accomplish assigned tasks. Admin access fails that test almost by definition, so every row needs either a narrower policy or a written reason.
Troubleshooting
SyntaxErrorinJSON.parse. A document wasn’t URL-decoded, or was decoded twice. The script triesdecodeURIComponentfirst and falls back to the raw text; keep that order if you adapt it.AccessDeniedonGetRolePolicyfor some roles. An SCP or an explicit deny in your audit role may block reads on some roles. The guide to troubleshoot AWS IAM access denied errors shows how to find which one.- No findings, but you know someone is admin. Their access may come from group membership (reported against the group), from a role they assume in another account, or from long-lived access keys on a user; the script to find IAM access keys older than 90 days or never used covers the last case.
To see what a specific principal can do right now, rather than what its policies say, the script to check the permissions of your currently assumed IAM role reads it from the caller’s side. To keep a history of how each policy changes after the review, check AWS Config is recording in every Region, including the one Region that records IAM global resource types.
Ask ChatWithCloud instead
You can ask ChatWithCloud “Which IAM users and roles have AdministratorAccess or a policy with Action *?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your AWS profile and summarizes the answer. It runs generated code without a confirmation step, which matters most for IAM, so connect ChatWithCloud to your AWS account using a read-only profile. The guide to analyze AWS security posture with an AI CLI has more IAM questions, and the ChatWithCloud security model lists what is sent for processing.
Frequently asked questions
How do I find which IAM users have admin access?
Run ListEntitiesForPolicy on AdministratorAccess, then scan customer managed and inline policies for Action "*" on Resource "*". Check group memberships too, because many users get admin through a group.
Is NotAction with Allow dangerous?
Usually. Allow with NotAction on Resource "*" grants everything except the listed actions, including actions for services added later.
Does Security Hub check inline policies for admin access?
No. Control IAM.1 checks only customer managed policies you create; it doesn’t check inline or AWS managed policies.
Should I delete the AdministratorAccess policy from every role?
Not blindly. Keep a documented break-glass role and the Organizations access role if you rely on it, and replace admin on automation roles with narrower policies.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud