GitHub OIDC to AWS: the trust policy mistake that keeps shipping to prod
The GitHub-to-AWS OIDC trust pattern has been GA since late 2021, and by 2026 it is the default story every cloud security team tells about getting rid of long-lived AKIA* keys in CI. Good story. The problem is that the trust policy on the AWS side — the JSON document attached to the IAM role that the runner assumes — is still being written by people who copy the first example from a blog post and stop reading once terraform plan works.
The result is roles that any workflow in the org can assume, sometimes any workflow on GitHub.com period. The number of public writeups in the last 18 months that traced back to exactly this shape — Unit 42, Datadog Security Labs, and several Mandiant retainers’ incident roundups — should have ended the pattern. It didn’t.
This is the post about catching it after the fact, because preventing it pre-merge is a separate fight (one your platform team is probably losing).
What the trust policy actually says
The relevant block on the AWS side looks like this:
{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" },
"StringLike": { "token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:*" }
}
}
The sub claim that GitHub mints into the JWT has a fixed grammar: repo:<owner>/<repo>:<context> where context is ref:refs/heads/main, pull_request, environment:prod, etc. The control surface is entirely in how tight that StringLike is.
The failure modes, in roughly descending frequency:
subcondition omitted entirely. The role trusts any GitHub Actions workflow on the planet that knows the role ARN. This still ships. I have no idea why AWS doesn’t reject the policy at write time, but they don’t.subset torepo:myorg/*. Any repo in the org can assume the role, including the intern’s fork that was made public last Tuesday.subset torepo:myorg/myrepo:*. Scoped to the repo but not the ref — pull request workflows from forks count, which is the AppSec briefing nobody reads.subset torepo:myorg/myrepo:ref:refs/heads/*. Any branch. Push a branch namedprod-deploy, get the prod role.audset to something other thansts.amazonaws.com. Rare but ugly, because some OIDC libraries will happily mint a token for a custom audience and then the role’s trust onaudbecomes decorative.
The :environment: form, when paired with GitHub’s environment protection rules, is the only version of this that holds up under adversarial review. Most teams don’t use it because it requires actually setting up environments in the repo, and the docs make it sound optional.
Detecting the abuse path in CloudTrail
The event you care about is AssumeRoleWithWebIdentity in the sts.amazonaws.com event source. The trap here is that the field most writeups point you at — additionalEventData — is not where the identity lives. For these events additionalEventData carries identityProviderConnectionVerificationMethod (value IAMTrustStore or Thumbprint), which tells you how AWS verified the provider, not who called. Useful for a different detection, useless for attribution.
The identity is in responseElements, which CloudTrail logs in full (minus secretAccessKey) for this API even though it’s a read-only call. The three fields that matter:
responseElements.subjectFromWebIdentityToken— thesubclaim, verbatim. For GitHub this is the structured stringrepo:<org>/<repo>:<context>, e.g.repo:myorg/myrepo:ref:refs/heads/mainorrepo:myorg/myrepo:pull_request. This is your primary attribution field. Everything you need to know about which repo and which ref assumed the role is parseable out of this one string.responseElements.provider— the OIDC provider ARN. Filter on this to isolate GitHub: it ends intoken.actions.githubusercontent.com.responseElements.audience— theaud. Should bests.amazonaws.com. Anything else is the decorative-audience failure mode showing up in telemetry.
Note what is not here: repository, repository_owner, workflow, ref, and head_ref are GitHub custom claims, and CloudTrail does not decode them into the record. They exist in the JWT GitHub mints, but AWS only surfaces sub, aud, and provider. If you want those claims as first-class queryable fields, you have to map them to session tags on the AWS side first — see the next subsection. Until you do, parse what you need out of subjectFromWebIdentityToken.
requestParameters.roleArn tells you which role got assumed. requestParameters.roleSessionName is what the caller named the session — as of aws-actions/configure-aws-credentials@v4 this defaults to GitHubActions unless overridden, which is unhelpful for attribution, so don’t build the detection on it. The session name is operator-controlled and trivially spoofed; subjectFromWebIdentityToken is minted by GitHub and validated by STS, so it’s the field you trust.
In Splunk, against the aws:cloudtrail sourcetype, the baseline query that tells you who is assuming what:
index=cloudtrail eventName=AssumeRoleWithWebIdentity
responseElements.provider="*token.actions.githubusercontent.com*"
| rename responseElements.subjectFromWebIdentityToken as gh_sub,
requestParameters.roleArn as role_arn,
responseElements.audience as gh_aud
| rex field=gh_sub "^repo:(?<gh_repo>[^:]+):(?<gh_context>.+)$"
| stats earliest(_time) as first_seen latest(_time) as last_seen count
by gh_repo role_arn gh_context
| sort gh_repo role_arn
gh_repo comes out as org/repo; gh_context is the rest of the sub (ref:refs/heads/main, pull_request, environment:prod, and so on). That stats ... by gh_repo role_arn is your repo-to-role map — the thing you stare at on day one.
The detection that fires on the cases you actually want — a new repo-to-role pair, or assumption from a ref that isn’t an allowlisted deployment branch:
index=cloudtrail eventName=AssumeRoleWithWebIdentity
responseElements.provider="*token.actions.githubusercontent.com*"
| rename responseElements.subjectFromWebIdentityToken as gh_sub,
requestParameters.roleArn as role_arn
| rex field=gh_sub "^repo:(?<gh_repo>[^:]+):(?<gh_context>.+)$"
| stats earliest(_time) as first_seen by gh_repo role_arn gh_context
| where first_seen > relative_time(now(), "-2d@d")
| search gh_context!="environment:*"
gh_context!="ref:refs/heads/main"
gh_context!="ref:refs/heads/master"
Run that over a 30-day window: anything whose first_seen lands in the last two days is a repo/role/context combination that wasn’t in your baseline. The environment: and known-deployment-branch exclusions are illustrative — swap in your actual allowlist. In production you want the new-pair logic backed by a lookup table of known-good (gh_repo, role_arn) pairs that you append to as you bless them, not a rolling time window; the window version is the thing you run by hand the first week before you’ve built the lookup.
A real caveat that will bite you: subjectFromWebIdentityToken only contains the ref when the sub uses the ref form. If a repo’s trust policy is scoped at repo:myorg/myrepo:* and GitHub mints a pull_request context, the sub is repo:myorg/myrepo:pull_request — there’s no branch in it, because the fork’s branch name isn’t in the sub grammar for that event type. That’s not a gap in your detection; it’s the trust policy telling you it accepted a fork PR without caring which branch. Treat pull_request as its own context value and alert on it for any role that should only be assumed by a deployment ref.
If you want repository / ref / head_ref as separate fields
You have to configure OIDC-claim-to-session-tag mapping — map the GitHub claims to principal tags on the role, the documented pattern (Aidan Steele’s writeup is the clearest reference). Once that’s in place, the mapped claims show up as session tags in requestParameters (and persist into downstream events if you mark them transitive), and you can query repository, ref, head_ref, actor, run_id directly instead of regexing them out of sub. Worth doing if you’re going to live in this data — head_ref in particular is what cleanly separates Renovate/Dependabot PR noise from everything else during tuning. Just know it’s a prerequisite you configure, not a field you get for free.
In the Elastic equivalent, the sub lands at aws.cloudtrail.response_elements.subject_from_web_identity_token after the Filebeat module parses the event — not under additional_eventdata. If you’ve set up the session-tag mapping above, the mapped tags arrive nested under requestParameters, and the older 8.11 ingest pipeline parses some of those nested request-parameter blobs inconsistently (stringified JSON on certain event shapes, needing a re-parse). The docs claim that’s fixed; check your pipeline before you trust the field path.
Two things to double-check before pasting: confirm your Splunk AWS TA extracts responseElements.provider as a first-class field (some TA versions extract it, some leave it inside a JSON blob you have to spath out first — if the baseline query returns nothing, that’s the first thing to check), and confirm the exact Elastic ECS field path against your own ingest, since the standard path is one thing and ECS mappings drift across module versions. Both are “verify against your own stack” rather than “trust the blog,” which is the right posture for copy-paste detections anyway.
What the first week of this detection looks like
Volume depends entirely on how much your platform team has built on GitHub Actions. For a shop with maybe 200 active repos hitting AWS, expect on the order of low thousands of AssumeRoleWithWebIdentity events per day. The baseline of repo-to-role pairs stabilizes within about ten days. After that, the alert that fires when a new pair appears is the one you actually want.
The first round of tuning will fix, in order:
- Renovate, Dependabot, and any other bot-driven PR workflow showing up as a “new” assumer every time it opens a PR from a branch like
renovate/aws-sdk-3.x. Thehead_refclaim is what gives it away — this is one of the cases the session-tag mapping in the previous section pays for, since without ithead_refandworkflowaren’t queryable fields. Suppress onworkflow IN ("renovate", "dependabot")or whatever your bot names them. - Release workflows assuming roles from tag refs (
ref:refs/tags/v1.2.3) which look like “new” refs every release. Either allowlist the tag pattern or pivot the dedup key off the ref entirely for release roles. - The one weird repo that uses
workflow_dispatchwith manually-typed branch names and pollutes the dedup table forever. Tag it and move on.
After that, what’s left in the alert queue is roughly: someone created a new repo and wired it to a sensitive role without going through the platform-team request flow. That’s the alert. It’s not always malicious — usually it’s a staff engineer who knew the role ARN and decided paperwork was for other people — but it’s always worth a ticket.
The false positives that survive tuning come from two places. First, monorepo splits, where a repo gets renamed or forked into two and the new sub claim is genuinely novel for a few days. Second, the org-renaming case (repository_owner changes), which historically broke a non-trivial number of trust policies and which your detection will surface as a flood. Both are operationally legible if you have a CMDB-ish source of truth for repos; if you don’t, the SOC analyst is going to ping the platform team in Slack and that’s fine, it’s the cheapest part of this.
The control you actually want is preventive
Detection is the consolation prize. The preventive control is a policy-as-code gate in the IAM pipeline that rejects any trust policy referencing the GitHub OIDC provider unless the sub condition uses StringEquals (not StringLike) and pins to a specific repo and a specific ref or environment. Cedar, OPA, or a homegrown jq script in the pre-merge hook — pick your weapon, but write the rule. Honestly, jq is fine for this; the policy shape is narrow enough that a 30-line script catches 95% of the bad ones, and the remaining 5% are the creative misuses that need a human anyway.
The second preventive control is rotating the OIDC provider thumbprint and audience and making sure nothing breaks — IA-5 hygiene nobody runs, which would catch the case where someone is trusting a stale thumbprint that GitHub deprecated months ago. Last I checked AWS auto-updates the thumbprint for token.actions.githubusercontent.com providers created via the managed pattern, but custom providers created before that change still need manual rotation. Verify on your own account; this behavior has shifted at least twice.
NIST 800-53 mapping
| Control | Why it applies |
|---|---|
| AC-2, AC-3 | Federation accounts and the conditions under which they’re authorized. Trust policy is the access enforcement point. |
| AC-6 | Roles assumed by CI workflows routinely have admin-tier policies. They should not. Separate plan-role from apply-role at minimum. |
| AU-2, AU-6 | AssumeRoleWithWebIdentity events with full responseElements (specifically subjectFromWebIdentityToken) must be in scope for the audit record set, and the review process has to know to parse the sub, not just read the role ARN. |
| CM-5 | Access restrictions for change — the IAM trust policy itself is configuration that needs review-gating. |
| IA-5 | OIDC tokens are short-lived authenticators; their issuance and verification need to be reasoned about as credentials. |
| SA-15, SR-3 | The CI/CD pipeline is a development tool with supply-chain exposure; trust between SCM and cloud is a supply-chain trust edge. |
SI-4 belongs in there too if you want to make the case that this detection is part of system monitoring. Some assessors buy that, some don’t.
What to do Monday
Pull a 30-day CloudTrail export, filter to AssumeRoleWithWebIdentity from the GitHub provider, project out responseElements.subjectFromWebIdentityToken and requestParameters.roleArn, and stare at the distinct pairs. If you can’t explain every one, you have your starting backlog. The roles with no sub condition at all are the ones to fix first — aws iam get-role --role-name <name> and look at AssumeRolePolicyDocument. They will not be hard to find.
The wildcards come next, and that’s the slower conversation, because every one of them belongs to a team that wrote it on purpose and will need to be talked out of it.
Sources
- Configuring OpenID Connect in Amazon Web Services (GitHub Docs)
- About security hardening with OpenID Connect (GitHub Docs)
- IAM JSON policy elements: Condition (AWS Documentation)
- aws-actions/configure-aws-credentials (GitHub)
- Logging IAM and AWS STS API calls with AWS CloudTrail (AWS Documentation)
- Oh-My-DC: OIDC Misconfigurations in CI/CD (Unit 42, Palo Alto Networks)
- No keys attached: Exploring GitHub-to-AWS keyless authentication flaws (Datadog Security Labs)
- Identifying vulnerabilities in GitHub Actions & AWS OIDC Configurations (Tinder Security Labs)
- NIST SP 800-53 Rev. 5 (NIST)