The Missing sub Condition: Why GitHub-to-AWS OIDC Is a Config Audit Before It’s a Detection
Keyless federation was the right call. Swapping long-lived AWS_ACCESS_KEY_ID pairs out of GitHub Actions secrets for short-lived STS credentials minted through OIDC removed an entire class of credential-theft incidents — the leaked key in a public repo, the key that outlived the engineer who created it, the key nobody rotated because three pipelines depended on it and no one knew which. Good. But the trust didn’t disappear. It moved. It moved from a secret you rotate into a JSON condition block on an IAM role’s trust policy, written once during the migration, approved in a PR nobody read closely, and never opened again. And the failure mode of that condition block is quieter than a leaked key, because there’s nothing to leak. The role just trusts more than whoever wrote it believed it did.
The specific hole is the sub (subject) condition. When a GitHub Actions job requests an OIDC token and calls sts:AssumeRoleWithWebIdentity, AWS validates the token’s claims against the conditions in the role’s trust policy. The sub claim is the one that encodes which repository, branch, and environment is asking. A trust policy that checks the audience (aud — sts.amazonaws.com for the default configure-aws-credentials setup) but forgets to check the subject will hand short-lived credentials to any GitHub repository on the planet that knows the role ARN. Not your org. Any org. The OIDC provider is token.actions.githubusercontent.com for all of GitHub, so the issuer check proves only that the caller is a GitHub Actions workflow, not your GitHub Actions workflow.
How the condition actually breaks
There are three shapes this takes, and they are not equally common.
The textbook version is the omitted sub entirely — the trust policy conditions on aud and stops. Datadog’s Security Labs writeup walks through exactly this, and it’s the one AWS finally moved to block: as of early 2026 you can no longer create a GitHub OIDC role trust policy that lacks a sub condition. Read that carefully, though. New policies. The change is not retroactive. Every trust policy created before the guardrail landed is grandfathered in with whatever hole it shipped with, and AWS is not going to rewrite your IAM for you. If you migrated to OIDC in 2023 or 2024 — which is most shops that did this early — the guardrail did nothing for you. Go grep your existing roles.
The second shape is the wildcard that’s too generous. repo:your-org/*:* reads, to the person who wrote it, as “our org’s repos.” It is not. It is every repository under the org, including the abandoned proof-of-concept, the intern’s fork-turned-repo, the throwaway someone spun up to test a GitHub Action and never deleted. Per Sadi Zane’s February 2026 writeup on exploiting organization wildcards, this is a staple of red-team post-exploitation precisely because it survives so well in production — the policy looks scoped, it has a sub condition, it passes a casual review, and it still trusts dozens of repos that have no business touching the role. The trailing :* on the ref is its own problem: it means any branch, any tag, and the pull_request context can assume the role. Fork PRs are gated here — GitHub denies id-token: write to workflows triggered from forks unless you’ve explicitly opened that door (or reached for pull_request_target), so a fork can’t mint a token by default. But that door does get opened, and a same-repo PR from a feature branch needs no such favor: if the role has any production write, the :* ref turns a pull-request workflow into a path to your prod account.
The third shape is the one that actually bites careful teams, because it bites through infrastructure-as-code. The GDS case Datadog documents is a duplicate StringEquals key in the Terraform that rendered the trust policy. JSON objects don’t allow duplicate keys; last one wins. So a block that looked like it conditioned on both aud and sub silently collapsed to whichever the serializer emitted last, dropping the subject check entirely. The Terraform plan looked fine. The applied policy did not. This is the kind of thing that never shows up in a detection because nothing anomalous happened at runtime — the misconfiguration is in the artifact, and the artifact validated.
That’s the thesis, and I’ll plant the flag here: this is a configuration-management problem wearing a detection’s badge. CloudTrail tells you a wrong repo assumed the role after STS already minted the credentials. By the time the event lands in your index, the federated session exists and has done whatever it was going to do. Detection is a backstop. The control is the trust policy.
What CloudTrail can and can’t give you
Still, you want the backstop, and you want to know its limits before you build a rule on top of it.
The AssumeRoleWithWebIdentity event does carry the subject. It shows up in two places: responseElements.subjectFromWebIdentityToken, formatted as repo:SOURCE-ORG/SOURCE-REPO:ref:refs/heads/BRANCH, and mirrored into userIdentity.username. The username field is the one to key on. It’s reliably populated and your pipeline almost certainly keeps it; responseElements is the field bag that ingestion configs love to trim to save on index volume, and if someone trimmed it eighteen months ago to cut your Splunk license burn, your detection that reads subjectFromWebIdentityToken will quietly match nothing and you will never get an alert telling you the field is gone. Key on userIdentity.username.
The Datadog-shaped detection, in CloudTrail Lake SQL, is an exclusion:
SELECT eventTime, userIdentity.username, requestParameters.roleArn
FROM <event_data_store>
WHERE eventName = 'AssumeRoleWithWebIdentity'
AND eventSource = 'sts.amazonaws.com'
AND userIdentity.username NOT LIKE 'repo:your-github-org/%'
In Splunk it’s the same logic against your CloudTrail sourcetype: filter eventName=AssumeRoleWithWebIdentity, then NOT username="repo:your-github-org/*". The shape is an allowlist-by-negation, and that’s deliberate. This should be a low-volume, high-signal detection. AssumeRoleWithWebIdentity fires roughly once per configure-aws-credentials step, so a busy monorepo can throw thousands of these a day, but the exclusion version should sit near zero. Anything that isn’t your org is the alert.
Near zero, not zero. Here’s where the first round of tuning goes, because the naive version floods.
The sub format is not one format. Environment-scoped jobs emit repo:org/repo:environment:production. Branch jobs emit repo:org/repo:ref:refs/heads/main. Tag builds emit :ref:refs/tags/v1.2.3. PRs emit repo:org/repo:pull_request. If your exclusion is a tight regex anchored on :ref:refs/heads/, every tag build and every environment deploy you run trips it on day one. The repo:your-org/% prefix match handles all of them; resist the urge to over-specify the suffix until you’ve watched a week of real traffic.
Reusable workflows split the identity. When a job calls a reusable workflow in another repo, the job_workflow_ref points at the workflow’s repo while the sub still reflects the caller — usually. The interaction between caller and called identity in the sub is fiddly and has shifted across GitHub releases, so if you run centralized reusable workflows (and if you have a platform team, you do), expect a cluster of legitimate-but-weird subjects in week one that you’ll need to read carefully before you allowlist them. Don’t blanket-exclude job_workflow_ref to make the noise go away; that’s the field an attacker abusing a poisoned shared workflow would hide behind.
GitHub Enterprise with multiple orgs breaks the single-prefix assumption outright. If you federate from three orgs into one AWS account, your exclusion needs all three prefixes, and the moment a fourth org gets stood up and wired in without telling security, it lands as an alert. That’s arguably correct behavior — you should know about a new org assuming your roles — but tell your SOC lead before they burn an afternoon on it.
The honest read: the false positives here aren’t really false. They’re governance gaps surfacing as alerts. That’s a feature, but only if someone’s actually watching a low-volume queue, and low-volume queues are exactly the ones that rot. A rule that fires twice a quarter is a rule nobody remembers exists by the time it matters.
Where the real fix lives
Push left of CloudTrail. The trust policy is a static artifact; audit it as one.
CSPM tooling (Wiz and Datadog both ship a rule for the missing-sub case; Prowler and Steampipe will find it too if you’d rather not pay) catches the deployed state. But the deployed state is downstream of the IaC, and the GDS duplicate-key bug proves the plan can look right while the apply is wrong, so scan both: the rendered policy in the account and the Terraform/CloudFormation that produced it — and prefer Terraform’s aws_iam_policy_document data source over hand-templated or templatefile()-rendered JSON, since it serializes conditions into a single well-formed map and structurally can’t emit the duplicate StringEquals key that collapsed the GDS policy. For the source side, an OPA/Conftest policy in CI that rejects any aws_iam_role trust statement federating token.actions.githubusercontent.com without a StringLike/StringEquals on :sub — and rejects a :sub value containing an org-level /* wildcard — kills two of the three shapes before merge.
The third shape, the over-broad wildcard that’s syntactically present, is where AWS’s February 2026 change earns its keep. STS now exposes nine provider-specific condition keys for GitHub beyond the packed sub string: repository, repository_id, actor, actor_id, job_workflow_ref, workflow, ref, environment, and enterprise_id. The sub condition stays mandatory, so these complement rather than replace it, but they let you express what the packed sub couldn’t cleanly: repository AND main branch AND production environment as separate ANDed conditions. The two immutable ones matter most. repository_id and actor_id don’t change when someone renames or recreates a repo, which closes the recreate-with-the-same-name trick that a pure name match leaves open — delete a repo your role trusts by name, and whoever creates the next repo with that name inherits the trust. Pin high-blast-radius production roles to repository_id and environment, accept that the values are opaque integers nobody can read at a glance, and document what they map to so the next engineer doesn’t “clean up” the magic numbers.
Same logic applies if you’re not on AWS. Azure federated identity credentials on app registrations match the subject exactly and historically rejected wildcards, which is stricter by default but means every branch or environment needs its own credential — a different failure mode, where teams over-broaden the subject pattern to dodge the credential sprawl. GCP Workload Identity Federation pushes the check into attribute conditions written in CEL, which is more expressive and correspondingly easier to write a condition that looks restrictive and isn’t. The platform changes the shape of the mistake. It doesn’t remove it.
Control mapping
| Concern | What it is | 800-53 |
|---|---|---|
| Trust policy scopes the federated identity to specific repos/branches/envs | Least privilege on the assumable role | AC-3, AC-6 |
| Federation of a non-org identity provider into the account | Authenticator + non-org user management | IA-5, IA-8 |
Trust policy and IaC scanned for missing/over-broad sub |
Configuration settings, least functionality | CM-6, CM-7 |
AssumeRoleWithWebIdentity reviewed for out-of-org subjects |
Audit review and analysis | AU-6 |
| Ongoing CSPM/IaC enforcement of the above | Continuous monitoring | CA-7 |
| CI/CD identity reaching production | Supply chain controls on the build path | SR-3, SR-4 |
The AU and CA rows are the backstop. The CM and AC rows are the fix. If you only build the SIEM rule, you’ve instrumented the moment of failure without preventing it — and the moment of failure, for this one, is after the credentials already exist. Audit the policy. Pin the immutable IDs on anything that can write to prod. Let CloudTrail be the thing that catches the org you forgot you federated, not the thing standing between a stranger’s repo and your account.
Sources
- No keys attached: Exploring GitHub-to-AWS keyless authentication flaws (Datadog Security Labs)
- Exploiting organisation wildcards in OIDC trust policies (Medium / Sadi Zane)
- Avoiding mistakes with AWS OIDC integration conditions (Wiz Blog)
- AWS STS now supports provider-specific claim validation for GitHub, Google, CircleCI, and OCI in OIDC federation (DevelopersIO / Classmethod)
- Identifying vulnerabilities in GitHub Actions & AWS OIDC configurations (Tinder Tech Blog / Medium)
- Logging IAM and AWS STS API calls with AWS CloudTrail (AWS Documentation)