Audit IAM SAML and OIDC Identity Providers

Close-up of a glowing fingerprint scanner on a dark security panel

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?

  1. Maps trust policiespaginateListRoles returns every role with its URL-encoded AssumeRolePolicyDocument. The script decodes it and indexes each Principal.Federated value.
  2. Reads SAML providersGetSAMLProvider returns the metadata document; Node’s X509Certificate reads the certificate dates.
  3. Reads OIDC providersGetOpenIDConnectProvider returns the audiences (ClientIDList) and creation date.
  4. Checks conditionsFor every sts:AssumeRoleWithWebIdentity statement it looks for <issuer>:aud and <issuer>:sub keys, compared in lower case because condition keys are case-insensitive.
  5. Grades findingsHIGH for a shared provider without its required claim or with a wildcard-only sub, MEDIUM for no aud or sub at all, LOW for a missing aud.

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 explains paginateListRoles.
  • 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?

idp-audit-policy.json

{
  "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

// 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?

Terminal

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

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 sub on GitHub. Pin the role to a repository and a branch or environment. GitHub’s OIDC reference for subject claims lists the formats, such as repo:acme/api:ref:refs/heads/main or repo: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 the sub check on it.
  • Missing aud. Add a StringEquals on <issuer>:aud with the client ID you registered, such as sts.amazonaws.com for GitHub Actions.
  • Unused provider. Check CloudTrail for recent AssumeRoleWithWebIdentity or AssumeRoleWithSAML events naming it, then delete it with DeleteOpenIDConnectProvider or DeleteSAMLProvider. Leave any AWSSSO_ 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

  • AccessDenied on GetSAMLProvider. 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 ok but deployments fail. The script only checks that conditions exist. A sub that 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 X509Certificate element 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