A Wildcard in Your OIDC Trust Policy Trusts Every Repo in the Org. CloudTrail Logs the One That Used It
Keyless authentication was supposed to be the easy win. You rip the long-lived AKIA... access keys out of GitHub Actions secrets, wire up an OIDC provider in IAM, and let each workflow mint short-lived STS credentials on demand. No static secret to leak, no rotation cron that’s been silently failing since the last person who understood it left. The control story maps cleanly to IA-5 and AC-2, the auditors nod, and everyone moves on.
The problem is what you wrote into the trust policy while you were moving on. The entire security boundary of GitHub-to-AWS OIDC lives in one JSON condition block on the role’s trust relationship, and the shape almost everyone ships is more permissive than they think. Datadog Security Labs scanned public repos and turned up over 500 unique role ARNs across more than 275 AWS accounts where the subject condition was wildcarded or missing entirely — meaning, in the worst cases, that any GitHub Actions workflow on the planet could assume the role and walk out with credentials. Not repos in your org. Any repo. Anyone’s.
That’s the shape of the problem. This is about how the misconfiguration actually works, what the detection looks like when it hits a real CloudTrail index, and why the tidy allowlist you build this week stops matching in July.
Where the trust actually lives
An AssumeRoleWithWebIdentity call presents a JWT signed by token.actions.githubusercontent.com. IAM validates the signature against the OIDC provider you registered, then checks the token’s claims against the Condition block in the role’s trust policy. Two claims carry the weight: aud (audience) and sub (subject).
The aud claim is close to useless as an access control by itself. GitHub, Gitpod, Harness, and half the vendor docs all tell you to set it to sts.amazonaws.com, which means every AWS customer on Earth shares the same audience value. Pinning aud proves the token was minted for AWS. It proves nothing about who minted it. If your trust policy conditions on aud alone — and some do, Teleport’s older integration guidance among them — you have effectively no subject restriction at all. One caveat worth stating precisely: after Datadog’s disclosure, AWS added a guardrail (as of June 2025) that blocks creating a new GitHub OIDC role whose trust policy carries no sub condition, so the naked aud-only role is mostly a legacy artifact now rather than something you can freshly provision. The guardrail isn’t retroactive, though — every already-created aud-only role stays exactly as exploitable as it was, which is precisely why the inventory sweep and the detection below still earn their keep.
So the sub claim is the whole game. For GitHub Actions it looks like this:
repo:octo-org/octo-repo:ref:refs/heads/main
Organization, repository, and the git ref that triggered the run. Lock the trust policy to that exact string with StringEquals and you’ve scoped the role to one branch of one repo. The trouble starts when people reach for StringLike to handle the fact that the ref portion varies — tags, environments, pull requests, and branches all produce different sub tails. The natural fix is a wildcard:
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:octo-org/*"
}
Read that carefully. repo:octo-org/* does not mean “any branch of our production repo.” It means any repository under the org — the abandoned proof-of-concept, the intern’s fork-turned-repo, the throwaway someone spun up to test a Dependabot config. Any workflow in any of those can assume the role. And because a fair number of orgs allow members to create repos freely, “any repo in the org” is a much larger trust boundary than the person who wrote repo:octo-org/* was picturing.
The wildcard belongs in the ref tail, not the repo segment. If you genuinely need to accept any branch of one repo, pin the org and repo and wildcard only the part that actually varies:
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:octo-org/octo-repo:ref:refs/heads/*"
}
That still trusts exactly one repository — it just stops caring which branch triggered the run. The dangerous version wildcards the org/repo segment; the narrower version pins the repository and wildcards only the ref. Call it narrower, not safe: repo:octo-org/octo-repo:ref:refs/heads/* still trusts every branch in that repo, which is a genuine trust decision — anyone who can create a branch or push a workflow change to one is inside the boundary. Pair it with branch protection, workflow-change controls, and, where you use them, GitHub environment protection rules; otherwise the ref wildcard quietly widens the moment someone adds a branch.
Then there’s the failure mode that doesn’t even look like a wildcard. Terraform’s aws_iam_role trust policy JSON will happily accept a document with two StringEquals keys for the same condition operator — one from a module default, one from your override. JSON object semantics say last key wins, so the subject restriction gets silently overwritten and you’re left with only the aud check. The plan output looks fine. The apply succeeds. The role trusts everyone. This is the duplicate-key variant Datadog documented, and it’s nastier than the wildcard because nothing in the source reads as permissive; you have to diff the rendered policy, not the HCL.
What the detection looks like in the index
CloudTrail logs every one of these. The event you want is AssumeRoleWithWebIdentity with eventSource of sts.amazonaws.com. The fields that matter:
userIdentity.type=WebIdentityUseruserIdentity.identityProvider= the token issuer,token.actions.githubusercontent.com(the fulloidc-provider/...provider ARN shows up separately inresponseElements.provider)responseElements.subjectFromWebIdentityTokencarries the decodedsub—repo:ORG/REPO:ref:refs/heads/BRANCH. This is the authoritative field to parse;userIdentity.userNamemay echo it but isn’t reliable across all runsrequestParameters.roleArnis the role being assumed
In Splunk, assuming you’ve got the CloudTrail sourcetype landing cleanly (field paths vary by add-on — some TAs flatten responseElements.subjectFromWebIdentityToken to responseElements_subjectFromWebIdentityToken, so adapt the paths below), the naive hunt is any web-identity assumption whose subject isn’t one of yours:
index=cloudtrail eventName=AssumeRoleWithWebIdentity eventSource=sts.amazonaws.com
| search userIdentity.identityProvider="*token.actions.githubusercontent.com"
| rex field=responseElements.subjectFromWebIdentityToken "repo:(?<gh_org>[^/@]+)(?:@(?<gh_owner_id>[0-9]+))?/(?<gh_repo>[^:@]+)(?:@(?<gh_repo_id>[0-9]+))?"
| search NOT gh_org IN ("your-org", "your-other-org")
| stats count values(requestParameters.roleArn) as roles by gh_org, gh_repo, sourceIPAddress
That rex is written to survive July on purpose. It pulls the org and repo names whether or not GitHub has appended the immutable @owner_id/@repo_id — the optional (?:@[0-9]+)? groups absorb the numeric IDs when they’re present and match nothing when they aren’t — so the extraction doesn’t silently break the week your repos start flipping formats. It also captures those IDs as gh_owner_id and gh_repo_id, which is the field you actually want to pivot and re-key onto (more on why below). A naive repo:(?<gh_org>[^/]+)/(?<gh_repo>[^:]+) would start mangling the org name into octo-org@123456 the moment the new format lands.
Here’s where lab-clean meets production. In a shop of any size, AssumeRoleWithWebIdentity is not a rare event — it fires on every CI job that touches AWS, which in a busy monorepo org can be thousands of times a day. The event itself is not the signal. The org-mismatch filter is what makes it tractable, and the first round of tuning is almost entirely about the allowlist and the noise underneath it.
Where the false positives come from. Reusable workflows change the sub in ways that surprise people — when a job runs from a called workflow, GitHub can populate the subject with the calling repo’s context or the job_workflow_ref, depending on how the token was requested, so a legitimate internal run shows up under a repo name your allowlist didn’t expect. Pull-request triggers use repo:ORG/REPO:pull_request in the default sub — not a :ref:refs/pull/N/merge tail (that context lives in the separate ref claim, not the subject), so a parser that assumes every GitHub subject has a :ref: segment silently misses PR-triggered runs entirely. Vendor integrations — your SaaS security scanner, your IaC tool — assume roles via their own OIDC providers, so if you widen the hunt past the GitHub issuer you’ll drown in Datadog’s and Wiz’s and Snyk’s legitimate assumptions. Start issuer-scoped and org-anchored, expand only when you understand what you’re adding.
The volume-and-retention tradeoff is real. STS management events are on by default in a standard trail, so you’re not paying extra to capture these — but if you’re routing CloudTrail to Splunk through a firehose and paying by ingest, the assume-role chatter is a meaningful slice of your IAM event volume, and it’s the kind of thing that gets sampled or dropped to hot-tier cost pressure right up until the week you need ninety days of it for an incident. Keep the web-identity events out of any sampling rule. They’re low-cardinality once parsed and you will want the history.
One gotcha that eats detections whole: STS logging location. Calls to the global sts.amazonaws.com endpoint historically logged to us-east-1, but regional STS endpoints log to their own region’s trail. If your org isn’t running a multi-region trail — and plenty of accounts brought under a late CA-7 continuous-monitoring push still aren’t — a workflow assuming a role through, say, the eu-west-1 regional endpoint never lands in the index you’re hunting. You’ll have a detection that looks healthy and a coverage hole you can’t see. Confirm the trail is multi-region before you trust the query.
The allowlist you build this week breaks in July
Now the part that turns this from a static hardening task into a moving one.
GitHub is changing the sub claim format. Per GitHub’s April 2026 changelog, the subject is getting immutable owner and repository IDs baked in, so repo:octo-org/octo-repo:ref:refs/heads/main becomes:
repo:octo-org@123456/octo-repo@456789:ref:refs/heads/main
The reason is sound — it closes a genuine repo-name-recycling attack. Rename or delete a repo, let someone else claim the old org/repo name, and under the old format they could mint tokens with a sub that still matches a trust policy pointing at the original identity. The immutable numeric IDs kill that. Repos created after July 15, 2026 get the new format automatically; renames and transfers after that date adopt it; existing repos stay on the old format unless you flip the toggle in the OIDC settings.
Here’s the operational sting. Every detection allowlist and every StringEquals trust policy that matches on repo:your-org/your-repo:... stops matching the moment that repo moves to the new format, because the string is now repo:your-org@123456/your-repo@456789:.... Your NOT LIKE 'repo:your-org/%' filter in the SIEM either starts flagging your own legitimate workflows as unknown, or — if you anchored it loosely — quietly stops filtering anything. Both are bad in different directions. One floods the SOC, the other blinds it.
The AWS side gives you the right answer, and it landed in February 2026: STS now supports GitHub’s OIDC claims as first-class IAM condition keys instead of forcing everything into sub. You can condition on token.actions.githubusercontent.com:repository_id, :environment, :job_workflow_ref, :actor_id, :ref independently, and compose them — repository and branch and environment, each evaluated on its own. The repository_id key is the one that matters most here: it’s the immutable numeric ID, so a trust policy pinned to it survives repository renames and doesn’t care which sub format GitHub is emitting. It’s the same coverage AWS shipped for Google, CircleCI, and OCI — and, since then, GitLab.com, whose namespace_id and project_id keys give you the same stable-ID pinning that survives group and project renames. The providers still without first-class keys are Microsoft Entra ID, Okta, Keycloak, and — a distinction worth drawing — GitLab Self-Managed and Dedicated, which remain sub-only even though GitLab.com no longer is. For any provider AWS doesn’t natively support, sub, aud, and amr are the only claims you can key on in a trust policy. Be precise about Entra specifically, though: it isn’t stuck parsing a messy, overloaded subject the way GitHub’s legacy format is. An Entra workload’s sub is already a clean, immutable identifier — the service principal’s object ID — and Entra’s claims-mapping lets you shape what the token carries, so you’re conditioning on a stable value rather than string-splitting a compound one. The real gap for those providers is the absence of composable, provider-specific condition keys, not that they force you into sub-string parsing.
Two different uses here, and it’s worth not conflating them. For IAM authorization, prefer the immutable repository_id condition key where AWS exposes it — that’s the trust-policy control, and it’s what survives a rename. For detection, know that CloudTrail does not log repository_id as its own field: AWS marks those GitHub keys “not available in session,” and the web-identity event only carries subjectFromWebIdentityToken, provider, and audience. So keep parsing subjectFromWebIdentityToken — but source the stable ID by pulling it out of the immutable sub once GitHub emits it (the @repo_id the rex above already captures), or by joining the org/repo name against a repository inventory. The name is the mutable field and the ID is the stable one; carry both through the transition rather than betting the detection on a field CloudTrail doesn’t give you.
Control mapping
| Concern | Control | What it actually means here |
|---|---|---|
| Trust scoped to exact repo/branch, least privilege | AC-6, AC-3 | StringEquals on subject or repository_id, not StringLike wildcards |
| No long-lived keys in CI | IA-5 | The reason you did OIDC at all; don’t undo it with a permissive trust |
| Trust-policy config baseline | CM-6 | Diff the rendered policy, not the HCL — catch the duplicate-key overwrite |
| Detection of unexpected assumptions | AU-6, AU-12 | The AssumeRoleWithWebIdentity hunt, org-anchored |
| Coverage across regions/accounts | CA-7 | Multi-region trail, or your detection has holes |
| Third-party actions holding the token | SR-3, SR-4 | A marketplace action running in a trusted repo can request the OIDC token too |
The last row is the one people skip. Scoping the trust policy correctly still hands the token to whatever runs inside a trusted workflow — including a third-party action you pinned to a tag that got force-pushed. Correct trust scope and supply-chain integrity of the workflow itself are two different controls, and the OIDC hardening only buys you the first one.
Do two things this quarter. Render every GitHub-to-AWS trust policy and grep the rendered JSON for StringLike on the subject and for any policy conditioning on aud alone — those are your wildcards and your name-recycling exposure. Then re-key your CloudTrail detection onto repository_id before July, because the allowlist you trust today is anchored to a string GitHub is about to change under you.
Sources
- No keys attached: Exploring GitHub-to-AWS keyless authentication flaws (Datadog Security Labs)
- AWS STS identity provider claims validation: fix overly broad OIDC trust policies (sjramblings.io)
- Avoiding mistakes with AWS OIDC integration conditions (Wiz Blog)
- Immutable subject claims for GitHub Actions OIDC tokens (GitHub Changelog)
- Configuring OpenID Connect in Amazon Web Services (GitHub Docs)
- CloudTrail userIdentity element (AWS Documentation)
- How to access AWS resources from Microsoft Entra ID tenants using AWS STS (AWS Security Blog)
- Create a role for OpenID Connect federation — provider-specific condition keys, incl. gitlab.com (AWS IAM)
- Exploiting organisation wildcards in OIDC trust policies (Medium)