Photo by Redd Francisco on Unsplash
To find IAM roles trusted by external accounts, page through ListRoles, decode each role’s AssumeRolePolicyDocument, and check every Allow statement for sts:AssumeRole*. Any AWS principal whose account ID isn’t yours (or in your organization), a "*" principal, or an OIDC provider without a subject condition is external trust. Cross-account trust without sts:ExternalId deserves a second look.
A role’s trust policy decides who can become that role. Its permissions policy decides what they can do afterwards, so a generous trust policy on a powerful role is one of the quietest ways to lose control of an account. Vendor integrations, old migrations and copy-pasted CloudFormation leave roles that other accounts can still assume years later. This example is for engineers who need to find IAM roles trusted by external accounts across a whole account, not only the roles nobody uses.
You’ll get a read-only TypeScript script for the AWS SDK for JavaScript v3 that lists every external principal with a severity. The example to find unused IAM roles with RoleLastUsed shows who trusts a role only when the role looks idle; this one audits every role, busy or not.
What counts as an external principal in a trust policy?
A trust policy is a resource policy on the role. The Principal element can name AWS accounts and IAM identities, AWS services, SAML providers or web identity (OIDC) providers. The script treats them like this:
| Principal in the trust policy | Verdict | Why |
|---|---|---|
"*" or {"AWS": "*"} with no principal condition |
CRITICAL | Any AWS identity in any account can assume it, as long as its own account allows sts:AssumeRole. |
GitHub OIDC without token.actions.githubusercontent.com:sub |
CRITICAL | Any workflow in any GitHub repository can request a token that matches. |
Another account’s root or role ARN, no sts:ExternalId |
HIGH | Cross-account trust with no confused-deputy protection. |
| Other web identity provider with no provider conditions | MEDIUM | Any identity from that provider may match. |
A unique ID such as AROA… instead of an ARN |
MEDIUM | The trusted role or user was deleted; IAM shows its ID. |
Your account, listed accounts, --org members, services, SAML |
skipped | Internal trust or AWS services acting on your behalf. |
"*" narrowed with aws:PrincipalOrgID, aws:PrincipalAccount or aws:PrincipalArn is reported as INFO, because a condition, not the principal, is doing the work there. Read those by hand.
Why does a missing external ID matter?
A vendor that assumes roles in many customer accounts is a textbook confused deputy, the pattern described in CWE-441: Unintended Proxy or Intermediary. If another customer of that vendor can type your role ARN into the vendor’s console, the vendor’s account assumes your role for them. The sts:ExternalId condition stops that: the vendor generates a unique ID per customer and always sends it with AssumeRole. AWS doesn’t treat the external ID as a secret, and it must be 2 to 1,224 characters. It matters for third parties that serve many customers; trust between your own accounts is better scoped with aws:PrincipalOrgID.
What about GitHub Actions roles?
IAM now rejects a trust policy for GitHub’s OIDC provider unless it evaluates token.actions.githubusercontent.com:sub with a value that isn’t just a wildcard. That check runs when a trust policy is created or updated, so roles written before it existed can still trust every repository on GitHub. The script flags them as CRITICAL.
What does the script do?
- Identifies your account
GetCallerIdentityreturns the account ID the profile is in. - Builds the internal listYour account, plus IDs passed with
--trusted=, plus every member account fromListAccountswhen you add--org. - Reads every trust policy
paginateListRolesreturns each role with its trust policy as a URL-encoded JSON string; the script decodes and parses it. Service-linked roles under/aws-service-role/are skipped. - Classifies each principalOnly
Allowstatements forsts:AssumeRole,AssumeRoleWithSAML,AssumeRoleWithWebIdentity,sts:*or*are evaluated. - Reports and exitsA table sorted by severity, and exit code 2 when anything is CRITICAL or HIGH, so a scheduled job can fail.
Prerequisites
- Node.js 18 or later, npm and
tsx. - The packages
@aws-sdk/client-iam,@aws-sdk/client-stsand@aws-sdk/client-organizations. - A profile set up as in the guide to AWS SDK v3 credential providers such as fromIni and fromSSO. IAM is global, so the Region doesn’t matter.
Which IAM permissions does it need?
Nothing here writes. GetCallerIdentity needs no permission at all, and the Organizations statement is only for --org.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadRoles",
"Effect": "Allow",
"Action": "iam:ListRoles",
"Resource": "*"
},
{
"Sid": "ListOrgAccountsOptional",
"Effect": "Allow",
"Action": "organizations:ListAccounts",
"Resource": "*"
}
]
}
ListAccounts works only from the management account or a delegated administrator; elsewhere the script prints a warning and carries on. To check what the profile you’re running as can actually do, use the script to check the permissions of your current assumed IAM role.
The script to find IAM roles trusted by external accounts
// find-iam-roles-trusted-by-external-accounts.ts
// Reads the trust policy of every IAM role and reports who outside your account (or organization) can
// assume it: other AWS accounts, Principal "*", and web identity (OIDC) providers without a subject check.
// Flags cross-account trust that has no sts:ExternalId condition (the confused deputy problem).
// Report-only: it never changes a role.
// Usage: npx tsx find-iam-roles-trusted-by-external-accounts.ts [--org] [--trusted=111122223333,444455556666] [--all]
import { IAMClient, paginateListRoles } from "@aws-sdk/client-iam";
import { OrganizationsClient, paginateListAccounts } from "@aws-sdk/client-organizations";
import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts";
const args = process.argv.slice(2);
const useOrg = args.includes("--org");
const showAll = args.includes("--all"); // also list external trust that looks fine (has an ExternalId)
const extraTrusted = (args.find((a) => a.startsWith("--trusted="))?.split("=")[1] ?? "")
.split(",").map((s) => s.trim()).filter((s) => /^\d{12}$/.test(s));
type Severity = "CRITICAL" | "HIGH" | "MEDIUM" | "INFO";
interface Finding {
Role: string;
Principal: string;
Account: string;
Severity: Severity;
Reason: string;
}
interface Statement {
Effect?: string;
Action?: string | string[];
Principal?: string | Record<string, string | string[]>;
Condition?: Record<string, Record<string, unknown>>;
}
const asArray = (v: string | string[] | undefined): string[] => (v === undefined ? [] : Array.isArray(v) ? v : [v]);
// IAM returns the trust policy URL-encoded; decode it only when needed.
function parsePolicy(raw: string): Statement[] {
const text = raw.trim().startsWith("{") ? raw : decodeURIComponent(raw);
const doc = JSON.parse(text) as { Statement?: Statement | Statement[] };
return Array.isArray(doc.Statement) ? doc.Statement : doc.Statement ? [doc.Statement] : [];
}
// Condition keys used anywhere in the statement, lowercased (keys are case-insensitive).
function conditionKeys(st: Statement): string[] {
return Object.values(st.Condition ?? {}).flatMap((block) => Object.keys(block).map((k) => k.toLowerCase()));
}
function allowsAssume(st: Statement): boolean {
if (st.Effect !== "Allow") return false;
return asArray(st.Action).some((a) => /^(\*|sts:\*|sts:assumerole.*)$/i.test(a));
}
// "123456789012", "arn:aws:iam::123456789012:root" or a role/user ARN -> account ID.
function accountOf(principal: string): string | undefined {
if (/^\d{12}$/.test(principal)) return principal;
return /^arn:aws[a-z-]*:(iam|sts)::(\d{12}):/.exec(principal)?.[2];
}
async function trustedAccounts(self: string): Promise<Set<string>> {
const trusted = new Set<string>([self, ...extraTrusted]);
if (!useOrg) return trusted;
try {
// Works from the management account or a delegated administrator account.
for await (const page of paginateListAccounts({ client: new OrganizationsClient({}) }, {})) {
for (const acct of page.Accounts ?? []) if (acct.Id) trusted.add(acct.Id);
}
} catch (err) {
console.error(`--org ignored: ${err instanceof Error ? err.name : String(err)} (run from the management or delegated admin account)`);
}
return trusted;
}
function analyzeStatement(role: string, st: Statement, trusted: Set<string>): Finding[] {
const keys = conditionKeys(st);
const has = (k: string) => keys.includes(k);
const scoped = has("aws:principalorgid") || has("aws:principalaccount") || has("aws:principalarn") || has("aws:principalorgpaths");
const findings: Finding[] = [];
const principals: [string, string][] =
st.Principal === "*"
? [["AWS", "*"]]
: Object.entries(st.Principal ?? {}).flatMap(([type, v]) => asArray(v).map((p): [string, string] => [type, p]));
for (const [type, p] of principals) {
if (type === "Service") continue; // AWS services acting for you; out of scope here
if (type === "AWS") {
if (p === "*") {
findings.push(scoped
? { Role: role, Principal: "*", Account: "-", Severity: "INFO", Reason: "Principal * limited by a principal condition" }
: { Role: role, Principal: "*", Account: "any", Severity: "CRITICAL", Reason: "any AWS principal can assume it" });
continue;
}
const acct = accountOf(p);
if (!acct) {
// A unique ID (AROA.../AIDA...) means the trusted principal was deleted.
findings.push({ Role: role, Principal: p, Account: "?", Severity: "MEDIUM", Reason: "unresolved principal (deleted role or user?)" });
continue;
}
if (trusted.has(acct)) continue;
if (has("sts:externalid")) {
if (showAll) findings.push({ Role: role, Principal: p, Account: acct, Severity: "INFO", Reason: "external account, ExternalId required" });
} else {
findings.push({ Role: role, Principal: p, Account: acct, Severity: "HIGH", Reason: "external account, no sts:ExternalId condition" });
}
continue;
}
if (type === "Federated") {
if (/saml-provider\//.test(p)) continue; // SAML providers live in your own account
const host = p.includes("oidc-provider/") ? p.split("oidc-provider/")[1] : p;
const hasSub = keys.some((k) => k === `${host.toLowerCase()}:sub`);
if (host === "token.actions.githubusercontent.com" && !hasSub) {
findings.push({ Role: role, Principal: host, Account: "-", Severity: "CRITICAL", Reason: "GitHub OIDC trust without a :sub condition" });
} else if (!keys.some((k) => k.startsWith(`${host.toLowerCase()}:`))) {
findings.push({ Role: role, Principal: host, Account: "-", Severity: "MEDIUM", Reason: "web identity trust without provider conditions" });
} else if (showAll) {
findings.push({ Role: role, Principal: host, Account: "-", Severity: "INFO", Reason: "web identity trust with provider conditions" });
}
}
}
return findings;
}
async function main(): Promise<void> {
const self = (await new STSClient({}).send(new GetCallerIdentityCommand({}))).Account ?? "";
const trusted = await trustedAccounts(self);
const findings: Finding[] = [];
let roles = 0;
for await (const page of paginateListRoles({ client: new IAMClient({}) }, {})) {
for (const role of page.Roles ?? []) {
roles++;
const name = role.RoleName ?? "?";
if ((role.Path ?? "").startsWith("/aws-service-role/")) continue; // service-linked roles are managed by AWS
try {
for (const st of parsePolicy(role.AssumeRolePolicyDocument ?? "{}")) {
if (allowsAssume(st)) findings.push(...analyzeStatement(name, st, trusted));
}
} catch {
findings.push({ Role: name, Principal: "-", Account: "-", Severity: "MEDIUM", Reason: "trust policy could not be parsed" });
}
}
}
const order: Record<Severity, number> = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, INFO: 3 };
findings.sort((a, b) => order[a.Severity] - order[b.Severity] || a.Role.localeCompare(b.Role));
console.table(findings);
const serious = findings.filter((f) => f.Severity === "CRITICAL" || f.Severity === "HIGH").length;
console.log(`${roles} role(s) read in account ${self}; ${trusted.size} account(s) treated as internal; ${serious} CRITICAL/HIGH finding(s).`);
if (serious) process.exitCode = 2;
}
main().catch((err) => {
console.error(err instanceof Error ? `${err.name}: ${err.message}` : err);
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-iam @aws-sdk/client-sts @aws-sdk/client-organizations
npm install --save-dev tsx typescript
# Everything outside this account
AWS_PROFILE=security-audit npx tsx find-iam-roles-trusted-by-external-accounts.ts
# Treat organization members and a known partner as internal, and show vetted external trust too
AWS_PROFILE=org-admin npx tsx find-iam-roles-trusted-by-external-accounts.ts --org --trusted=444455556666 --all
Run it in each account you own; roles are per account. ListRoles returns 100 roles per page by default (1,000 at most), and the paginator follows the marker for you.
Sample output
┌─────────┬──────────────────────────┬────────────────────────────────────────────┬────────────────┬────────────┬─────────────────────────────────────────────────┐
│ (index) │ Role │ Principal │ Account │ Severity │ Reason │
├─────────┼──────────────────────────┼────────────────────────────────────────────┼────────────────┼────────────┼─────────────────────────────────────────────────┤
│ 0 │ 'gha-deploy-prod' │ 'token.actions.githubusercontent.com' │ '-' │ 'CRITICAL' │ 'GitHub OIDC trust without a :sub condition' │
│ 1 │ 'legacy-migration-admin' │ 'arn:aws:iam::210987654321:root' │ '210987654321' │ 'HIGH' │ 'external account, no sts:ExternalId condition' │
│ 2 │ 'monitoring-vendor-ro' │ 'arn:aws:iam::777788889999:role/collector' │ '777788889999' │ 'HIGH' │ 'external account, no sts:ExternalId condition' │
│ 3 │ 'old-ci-runner' │ 'AROA3EXAMPLEEXAMPLE7Q' │ '?' │ 'MEDIUM' │ 'unresolved principal (deleted role or user?)' │
│ 4 │ 'shared-reader' │ '*' │ '-' │ 'INFO' │ 'Principal * limited by a principal condition' │
└─────────┴──────────────────────────┴────────────────────────────────────────────┴────────────────┴────────────┴─────────────────────────────────────────────────┘
184 role(s) read in account 111122223333; 1 account(s) treated as internal; 3 CRITICAL/HIGH finding(s).
The names and IDs are illustrative. The first two rows are the ones to fix today: a deployment role any GitHub repository can assume, and an admin role left over from a migration that trusts a whole foreign account.
How do you fix each finding?
- GitHub OIDC without
:sub. Add aStringEqualsorStringLikecondition ontoken.actions.githubusercontent.com:subnaming your repository and branch or environment, for examplerepo:my-org/my-app:environment:production. - Vendor role with no external ID. Ask the vendor for the external ID they assigned to your account and add
"StringEquals": {"sts:ExternalId": "…"}. A vendor that can’t provide one is worth questioning. - Trust in a whole account. Replace
arn:aws:iam::ACCOUNT:rootwith the specific role that needs access, or scope it withaws:PrincipalOrgIDif it’s your own account. - Unresolved IDs and dead migrations. Check the role’s last use and delete it if it’s idle; the permissions it grants are the real exposure. The script to find IAM policies that grant admin access shows which of these roles would hand over the most.
After editing a trust policy, test it with the external party before you close the ticket. The guide to troubleshoot AWS IAM access denied errors step by step helps when a legitimate caller gets locked out. Then trim the permissions policy of the same role to what the external party actually needs.
What the script can’t see
- Deny statements and organization policies. A Deny in the trust policy, an SCP or a resource control policy can block a principal the script reports. It reads Allow statements only.
- Other resource policies. S3 buckets, KMS keys, queues, topics and machine images can also grant access to other accounts; this audit covers roles. The scripts to find public SNS topics and SQS queues and to find public AMIs you’ve shared by mistake cover three of the others.
- Use. It shows who can assume a role, not who did. CloudTrail
AssumeRoleevents answer that; the script to check CloudTrail is enabled and logging in every AWS Region makes sure those events exist.
IAM Access Analyzer’s external access analyzer, which AWS offers at no additional charge, runs a similar check continuously for roles and many resource types, using your account or organization as the zone of trust. The script is useful for a quick, explainable report, for CI, and for accounts where nobody has turned the analyzer on. To see which Regions have an active analyzer, run the script to check IAM Access Analyzer in all Regions and list findings.
Ask ChatWithCloud instead
ChatWithCloud turns a plain-English question into AWS SDK for JavaScript v2 code, runs it on your machine with your profile and sends the JSON result to the AI model to write the answer. Ask “Which IAM roles can be assumed by accounts other than this one?” or “Does the role gha-deploy-prod have a sub condition in its trust policy?” and it reads the same trust policies. Generated code runs without a confirmation step, so use a read-only AWS profile for ChatWithCloud and read the ChatWithCloud security model first. The guide to analyze your AWS security posture with an AI CLI shows more questions of this kind.
Frequently asked questions
How do I see which AWS accounts can assume a role?
Read the role’s trust policy: GetRole or ListRoles returns it as AssumeRolePolicyDocument. Account IDs appear in Principal.AWS as 12-digit IDs or ARNs. Conditions such as aws:PrincipalOrgID can narrow a wider principal.
Is an external ID a secret?
No. AWS says anyone who can view the role can see it. Its job is to make a multi-customer vendor prove which customer it’s acting for, which prevents the confused deputy problem.
Does trusting arn:aws:iam::ACCOUNT:root give that account’s root user access?
It trusts the whole account: any identity there that its own administrators allow to call sts:AssumeRole on your role can use it. Naming a specific role ARN is narrower.
Why does my trust policy show AROA… instead of a role ARN?
The role it trusted was deleted, so IAM displays its unique ID. Recreating a role with the same name doesn’t restore the trust; edit the policy to name the new role.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud