
Photo by panumas nikhomkhai on Pexels
An IAM OIDC identity provider audit lists every SAML and OIDC provider with ListSAMLProviders and ListOpenIDConnectProviders, then reads every role trust policy from ListRoles to see which roles trust each provider and under what conditions. Flag providers no role uses, OIDC roles without an aud or sub condition, and GitHub Actions roles whose sub is missing or a wildcard.
Identity providers are the quiet part of IAM. Someone adds GitHub Actions, GitLab or an Okta SAML app once, a few roles get trust policies copied from a blog post, and nobody looks again. Those roles never need access keys, which is the point, but a loose condition on a shared provider lets identities outside your organization assume them.
This example is for platform and security engineers. The script runs an IAM OIDC identity provider audit across the whole account: it lists SAML and OIDC providers, maps each to the roles that trust it, checks the aud and sub conditions, and reads the X.509 certificates inside each SAML metadata document. It’s report only and changes nothing.
What should an IAM OIDC identity provider audit check?
Shared providers with weak claims. An issuer like token.actions.githubusercontent.com is the same for every GitHub customer. The only thing that ties a token to your repositories is the sub claim, so a trust policy without a sub condition trusts every workflow on GitHub. IAM now calls these required claims identity-provider controls for shared OIDC providers and rejects a new or updated trust policy that skips them, returning MalformedPolicyDocument. The same page is explicit that existing trust policies aren’t evaluated. Roles created before the check are exactly what an audit has to find.
Unused providers. A provider that no role trusts does nothing today, but it’s one trust policy away from being used again. If the tool it belonged to is gone, delete it.
SAML certificate expiry. The “Valid until” date IAM shows for a SAML provider is set by IAM when the provider is created. It doesn’t come from the metadata, and IAM doesn’t act on expired X.509 certificates in the metadata during sign-in. AWS recommends you monitor those dates yourself, so the script decodes each certificate and reports the earliest expiry.
Federated roles are one kind of external trust. For the other kind, roles that trust principals in other AWS accounts, use the example to find IAM roles trusted by external AWS accounts.
What does the script do?
- Maps trust policies
paginateListRolesreturns every role with its URL-encodedAssumeRolePolicyDocument. The script decodes it and indexes eachPrincipal.Federatedvalue. - Reads SAML providers
GetSAMLProviderreturns the metadata document; Node’sX509Certificatereads the certificate dates. - Reads OIDC providers
GetOpenIDConnectProviderreturns the audiences (ClientIDList) and creation date. - Checks conditionsFor every
sts:AssumeRoleWithWebIdentitystatement it looks for<issuer>:audand<issuer>:subkeys, compared in lower case because condition keys are case-insensitive. - Grades findingsHIGH for a shared provider without its required claim or with a wildcard-only
sub, MEDIUM for noaudorsubat all, LOW for a missingaud.
Built-in providers such as Amazon Cognito (cognito-identity.amazonaws.com) appear in trust policies without an IAM provider resource. The script checks those statements too, and Cognito’s required claim is aud.
Prerequisites
- Node.js 18 or later,
tsx, and@aws-sdk/client-iam. The guide to paginating AWS APIs with SDK v3 paginators explainspaginateListRoles. - Credentials for each account you audit. IAM is global, so one run covers the account; run it per account in an organization.
- A list of the tools your team actually federates with, so you can tell a forgotten provider from a live one.
Which IAM permissions does it need?
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListRolesAndProviders",
"Effect": "Allow",
"Action": ["iam:ListRoles", "iam:ListSAMLProviders", "iam:ListOpenIDConnectProviders"],
"Resource": "*"
},
{
"Sid": "ReadProviders",
"Effect": "Allow",
"Action": ["iam:GetSAMLProvider", "iam:GetOpenIDConnectProvider"],
"Resource": [
"arn:aws:iam::111122223333:saml-provider/*",
"arn:aws:iam::111122223333:oidc-provider/*"
]
}
]
}
All five actions are read-only. The list actions don’t support resource-level permissions, so they need "Resource": "*". The IAM policy generator for TypeScript code drafts the same policy from the script, and the checklist to review a generated IAM policy for least privilege covers what to tighten.
The script for an IAM OIDC identity provider audit
// audit-iam-identity-providers.ts
// Lists every IAM SAML and OIDC identity provider in the account, the roles that trust each one, and the
// trust policies that are too loose: OIDC roles without an aud or sub condition, and roles for shared
// providers such as GitHub Actions whose sub condition is missing or only a wildcard. It also reads the
// X.509 certificates inside each SAML metadata document, because IAM does not act on their expiry.
// Report only: nothing is changed.
// Usage: npx tsx audit-iam-identity-providers.ts [--cert-warn-days 30]
import { X509Certificate } from "node:crypto";
import {
IAMClient,
GetOpenIDConnectProviderCommand,
GetSAMLProviderCommand,
ListOpenIDConnectProvidersCommand,
ListSAMLProvidersCommand,
paginateListRoles,
} from "@aws-sdk/client-iam";
const args = process.argv.slice(2);
const warnIdx = args.indexOf("--cert-warn-days");
const certWarnDays = warnIdx >= 0 ? Number(args[warnIdx + 1]) : 30;
if (!Number.isFinite(certWarnDays) || certWarnDays < 0) {
console.error("--cert-warn-days must be a number (0 or more)");
process.exit(1);
}
const iam = new IAMClient({ region: "us-east-1" }); // IAM is global; any Region works
const DAY = 86_400_000;
const day = (d?: Date): string => (d ? d.toISOString().slice(0, 10) : "-");
const errText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));
// Shared OIDC issuers where IAM now requires a claim check (identity-provider controls). Not the full list.
const SHARED_OIDC: Record<string, "sub" | "aud"> = {
"token.actions.githubusercontent.com": "sub",
"gitlab.com": "sub",
"app.terraform.io": "sub",
"agent.buildkite.com": "sub",
"oidc.codefresh.io": "sub",
"scalr.io": "sub",
"api.pulumi.com/oidc": "aud",
"oidc.vercel.com": "aud",
"cognito-identity.amazonaws.com": "aud",
};
interface Statement {
Effect?: string;
Principal?: { Federated?: string | string[] } | string;
Action?: string | string[];
Condition?: Record<string, Record<string, string | string[]>>;
}
interface TrustingRole {
role: string;
statement: Statement;
}
const asArray = (v: string | string[] | undefined): string[] => (v === undefined ? [] : Array.isArray(v) ? v : [v]);
// Condition keys are case-insensitive, so compare them in lower case.
function conditionValues(stmt: Statement, key: string): string[] {
const values: string[] = [];
for (const block of Object.values(stmt.Condition ?? {})) {
for (const [k, v] of Object.entries(block)) {
if (k.toLowerCase() === key.toLowerCase()) values.push(...asArray(v));
}
}
return values;
}
const onlyWildcards = (v: string): boolean => /^[*?]+$/.test(v);
function samlCertExpiry(metadata: string): Date | undefined {
const dates: Date[] = [];
for (const m of metadata.matchAll(/<(?:\w+:)?X509Certificate>([^<]+)<\/(?:\w+:)?X509Certificate>/g)) {
try {
const der = Buffer.from(m[1].replace(/\s+/g, ""), "base64");
dates.push(new Date(new X509Certificate(der).validTo));
} catch {
// not a parseable certificate; skip it
}
}
return dates.sort((a, b) => a.getTime() - b.getTime())[0];
}
async function main(): Promise<void> {
// 1. Map every federated principal in every role trust policy.
const trustedBy = new Map<string, TrustingRole[]>();
for await (const page of paginateListRoles({ client: iam }, {})) {
for (const role of page.Roles ?? []) {
if (!role.RoleName || !role.AssumeRolePolicyDocument) continue;
const doc = JSON.parse(decodeURIComponent(role.AssumeRolePolicyDocument)) as { Statement?: Statement | Statement[] };
const statements = Array.isArray(doc.Statement) ? doc.Statement : doc.Statement ? [doc.Statement] : [];
for (const stmt of statements) {
if (stmt.Effect !== "Allow" || typeof stmt.Principal !== "object") continue;
for (const fed of asArray(stmt.Principal.Federated)) {
trustedBy.set(fed, [...(trustedBy.get(fed) ?? []), { role: role.RoleName, statement: stmt }]);
}
}
}
}
const providerRows: Record<string, string | number>[] = [];
const findings: Record<string, string>[] = [];
// 2. SAML providers.
const saml = await iam.send(new ListSAMLProvidersCommand({}));
for (const p of saml.SAMLProviderList ?? []) {
if (!p.Arn) continue;
const name = p.Arn.split(":saml-provider/")[1] ?? p.Arn;
const detail = await iam.send(new GetSAMLProviderCommand({ SAMLProviderArn: p.Arn }));
const certExpiry = samlCertExpiry(detail.SAMLMetadataDocument ?? "");
const roles = trustedBy.get(p.Arn) ?? [];
let finding = "ok";
if (!roles.length) finding = "UNUSED: no role trusts it";
if (certExpiry && certExpiry.getTime() < Date.now()) finding = "CERT EXPIRED in metadata";
else if (certExpiry && certExpiry.getTime() < Date.now() + certWarnDays * DAY) finding = `cert expires within ${certWarnDays} days`;
if (name.startsWith("AWSSSO_")) finding = "managed by IAM Identity Center: leave it";
providerRows.push({
Type: "SAML",
Provider: name,
Created: day(detail.CreateDate ?? p.CreateDate),
CertExpires: day(certExpiry),
Audiences: "-",
Roles: roles.length,
Finding: finding,
});
}
// 3. OIDC providers.
const oidc = await iam.send(new ListOpenIDConnectProvidersCommand({}));
for (const p of oidc.OpenIDConnectProviderList ?? []) {
if (!p.Arn) continue;
const host = p.Arn.split(":oidc-provider/")[1] ?? p.Arn;
const detail = await iam.send(new GetOpenIDConnectProviderCommand({ OpenIDConnectProviderArn: p.Arn }));
const roles = trustedBy.get(p.Arn) ?? [];
providerRows.push({
Type: "OIDC",
Provider: host,
Created: day(detail.CreateDate),
CertExpires: "-",
Audiences: (detail.ClientIDList ?? []).join(", ") || "-",
Roles: roles.length,
Finding: roles.length ? "ok" : "UNUSED: no role trusts it",
});
}
// 4. Trust policy checks for every OIDC principal, including built-in ones such as Cognito.
for (const [principal, roles] of trustedBy) {
if (principal.includes(":saml-provider/")) continue;
const host = principal.includes(":oidc-provider/") ? principal.split(":oidc-provider/")[1] : principal;
for (const { role, statement } of roles) {
if (!asArray(statement.Action).some((a) => a.toLowerCase() === "sts:assumerolewithwebidentity")) continue;
const aud = conditionValues(statement, `${host}:aud`);
const sub = conditionValues(statement, `${host}:sub`);
const required = SHARED_OIDC[host];
let risk = "";
if (required === "sub" && !sub.length) risk = "HIGH: shared provider, no sub condition";
else if (required === "sub" && sub.some(onlyWildcards)) risk = "HIGH: sub is only a wildcard";
else if (host === "token.actions.githubusercontent.com" && sub.some((v) => /^repo:[*?]/.test(v))) {
risk = "HIGH: sub allows any GitHub org";
} else if (required === "aud" && !aud.length) risk = "HIGH: shared provider, no aud condition";
else if (!aud.length && !sub.length) risk = "MEDIUM: no aud or sub condition";
else if (!aud.length) risk = "LOW: no aud condition";
if (risk) findings.push({ Role: role, Provider: host, Aud: aud.join(", ") || "-", Sub: sub.join(", ") || "-", Risk: risk });
}
}
console.log("Identity providers");
console.table(providerRows);
console.log("Roles with loose OIDC trust policies");
if (findings.length) console.table(findings);
else console.log(" none");
const unused = providerRows.filter((r) => String(r.Finding).startsWith("UNUSED")).length;
console.log(
`${providerRows.length} providers (${unused} unused), ${findings.length} risky trust statements. Report only: nothing was changed.`,
);
}
main().catch((err) => {
console.error(errText(err));
process.exit(1);
});
The SHARED_OIDC map holds a few issuers from AWS’s list of shared providers, with the claim IAM requires for each. Add any other shared issuer you use from that list.
How do you run it?
npm install @aws-sdk/client-iam
npm install --save-dev tsx typescript @types/node
AWS_PROFILE=security-audit npx tsx audit-iam-identity-providers.ts --cert-warn-days 45
Sample output
Identity providers
┌─────────┬────────┬───────────────────────────────────────────┬──────────────┬──────────────┬─────────────────────┬───────┬────────────────────────────────────────────┐
│ (index) │ Type │ Provider │ Created │ CertExpires │ Audiences │ Roles │ Finding │
├─────────┼────────┼───────────────────────────────────────────┼──────────────┼──────────────┼─────────────────────┼───────┼────────────────────────────────────────────┤
│ 0 │ 'SAML' │ 'Okta' │ '2021-03-02' │ '2027-08-19' │ '-' │ 2 │ 'cert expires within 45 days' │
│ 1 │ 'SAML' │ 'AWSSSO_5f1c2e_DO_NOT_DELETE' │ '2023-01-10' │ '2028-01-10' │ '-' │ 4 │ 'managed by IAM Identity Center: leave it' │
│ 2 │ 'OIDC' │ 'token.actions.githubusercontent.com' │ '2022-05-01' │ '-' │ 'sts.amazonaws.com' │ 3 │ 'ok' │
│ 3 │ 'OIDC' │ 'oidc.eks.us-east-1.amazonaws.com/id/7C1' │ '2023-08-14' │ '-' │ 'sts.amazonaws.com' │ 1 │ 'ok' │
│ 4 │ 'OIDC' │ 'auth.example.com' │ '2020-11-20' │ '-' │ 'web-app' │ 0 │ 'UNUSED: no role trusts it' │
└─────────┴────────┴───────────────────────────────────────────┴──────────────┴──────────────┴─────────────────────┴───────┴────────────────────────────────────────────┘
Roles with loose OIDC trust policies
┌─────────┬──────────────────┬───────────────────────────────────────────┬─────────────────────┬─────────────────────────────────┬───────────────────────────────────────────┐
│ (index) │ Role │ Provider │ Aud │ Sub │ Risk │
├─────────┼──────────────────┼───────────────────────────────────────────┼─────────────────────┼─────────────────────────────────┼───────────────────────────────────────────┤
│ 0 │ 'gh-legacy-ci' │ 'token.actions.githubusercontent.com' │ 'sts.amazonaws.com' │ '-' │ 'HIGH: shared provider, no sub condition' │
│ 1 │ 'gh-any-repo' │ 'token.actions.githubusercontent.com' │ 'sts.amazonaws.com' │ 'repo:*' │ 'HIGH: sub allows any GitHub org' │
│ 2 │ 'eks-irsa-app' │ 'oidc.eks.us-east-1.amazonaws.com/id/7C1' │ '-' │ 'system:serviceaccount:app:api' │ 'LOW: no aud condition' │
│ 3 │ 'cognito-unauth' │ 'cognito-identity.amazonaws.com' │ '-' │ '-' │ 'HIGH: shared provider, no aud condition' │
└─────────┴──────────────────┴───────────────────────────────────────────┴─────────────────────┴─────────────────────────────────┴───────────────────────────────────────────┘
5 providers (1 unused), 4 risky trust statements. Report only: nothing was changed.
Names and dates are illustrative. Two of the three GitHub roles are the real problem: gh-legacy-ci has no sub condition, and gh-any-repo allows repo:*, so a workflow in any GitHub organization that knows the role ARN could get credentials. The Okta signing certificate expires within the warning window, which IAM itself won’t flag. auth.example.com is a leftover from a retired app.
How do you fix each finding?
- Missing or wildcard
subon GitHub. Pin the role to a repository and a branch or environment. GitHub’s OIDC reference for subject claims lists the formats, such asrepo:acme/api:ref:refs/heads/mainorrepo:acme/api:environment:production. It also notes that repositories created after July 15, 2026 get a default subject that includes owner and repository IDs, so check which format your tokens carry before you edit. Once you save the policy, IAM enforces thesubcheck on it. - Missing
aud. Add aStringEqualson<issuer>:audwith the client ID you registered, such assts.amazonaws.comfor GitHub Actions. - Unused provider. Check CloudTrail for recent
AssumeRoleWithWebIdentityorAssumeRoleWithSAMLevents naming it, then delete it withDeleteOpenIDConnectProviderorDeleteSAMLProvider. Leave anyAWSSSO_provider alone: IAM Identity Center manages those. - Expiring SAML certificate. Rotate it in your IdP, download the new metadata and upload it with
UpdateSAMLProvider. The roles that trust it don’t change.
After fixing trust policies, look at what the roles can do once assumed. A federated role nobody has used in months is a candidate for the example to find unused IAM roles with RoleLastUsed. IAM Access Analyzer also reports roles shared outside your zone of trust; the example to check IAM Access Analyzer in every Region shows whether it’s on.
Troubleshooting
AccessDeniedonGetSAMLProvider. The resource ARN in the policy must match the provider type. The walkthrough to troubleshoot AWS IAM access denied errors shows how to find the statement that blocks it.- A GitHub role shows
okbut deployments fail. The script only checks that conditions exist. Asubthat uses repository names won’t match tokens in the ID-based format, and the reverse. Compare the policy with a token’s claims. - No certificate date for a SAML provider. The metadata has no
X509Certificateelement the script could parse. Open the metadata and check the signing key manually. - Testing without an account. The guide to mock AWS SDK v3 clients in unit tests shows how to feed fake roles and providers to
IAMClient.
Ask ChatWithCloud instead
For a quick look, ask ChatWithCloud “Which IAM roles trust token.actions.githubusercontent.com, and what are their sub conditions?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile, and answers from the result. That fits the kind of review in the guide to analyze your AWS security posture with an AI CLI. It can be wrong and runs changes without a confirmation step, so use a read-only profile, as the ChatWithCloud security model recommends. If your AWS access itself comes through IAM Identity Center, the guide to connect ChatWithCloud to an AWS SSO profile covers the setup.
Frequently asked questions
Does IAM check existing GitHub OIDC trust policies for a sub condition?
No. IAM checks for the required claim when a trust policy is created or updated. Existing policies aren’t evaluated until someone edits them, which is why an audit is needed.
What does ValidUntil on an IAM SAML provider mean?
It’s a date IAM sets when the provider is created. It doesn’t reflect the metadata’s validUntil attribute or the certificate dates, and IAM doesn’t block sign-in when metadata certificates expire.
Do I still need thumbprints for an OIDC provider?
AWS verifies the provider’s JWKS endpoint against its library of trusted root CAs. It falls back to the thumbprints you configured only when the certificate isn’t signed by one of those CAs, or when it can’t retrieve the certificate.
Is it safe to delete an unused OIDC provider?
If no role trusts it and CloudTrail shows no recent federation through it, deleting it is low risk. You can create the provider again with the same URL if a tool needs it later.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud