The Audience Is the One Claim Your Attacker Picks
GitHub Actions OIDC hardening belongs in your trust policy, not your workflow file
The development
On 10 August, a post on blog.yossarian.net argued that GitHub Actions should let a workflow declare up front which audiences it may request an OIDC token for, and sketched a permission syntax taking a list of allowed audiences instead of a single write toggle. The comparison it rests on is GitLab, where ID tokens are declared per job under the id_tokens keyword: each named token carries its own aud value in the pipeline file, and GitLab documents an omitted aud as defaulting to the domain of the GitLab instance. None of it is chosen at run time.
The story is two days old. The reason to write about it now is that the fix people will ask GitHub for is not the fix that protects them this quarter.
What the schema actually says
GitHub's workflow syntax reference enumerates the permission scopes and the values each one accepts. Most take read, write, or none. Two do not: vulnerability-alerts takes read or none, and id-token takes write or none. No scope has a list form. The proposal is a proposal precisely because the schema has nowhere to put it.
One page further, in GitHub's OpenID Connect reference, the asymmetry sharpens. The subject claim is administrable: there are REST endpoints at /repos/{owner}/{repo}/actions/oidc/customization/sub and /orgs/{org}/actions/oidc/customization/sub, and their body takes include_claim_keys, an array naming which claims compose sub, alongside use_default. The audience has no counterpart. The default audience is documented as the URL of the repository owner, a workflow overrides it by passing a value to getIDToken, and GitHub documents no API, setting, or syntax that constrains which value that may be.
So the identity half of the token is governable and the audience half is not. That is worse than a missing feature. Every other claim — repository, job_workflow_ref, ref, environment, actor, runner_environment — is fixed by the workflow run before any step executes. aud is the one the caller parameterizes per request. A compromised third-party step inside a job holding id-token: write can vary exactly one field of the identity it presents, and that is the field.
The assumption this invalidates
So the assumption to drop is that the audience is a security boundary.
Read the relying party rather than the issuer. AWS writes trust conditions as the provider name followed by a claim, so token.actions.githubusercontent.com:aud. Its own minimal GitHub example pins that key to sts.amazonaws.com and pairs it with token.actions.githubusercontent.com:sub scoped to a repository and a branch ref.
Consider what that first condition is worth. sts.amazonaws.com carries nothing account-specific — set it beside the Cognito example a few entries down in the same reference, which embeds an identity-pool ID. A condition pinned to a value with no tenant in it excludes tokens minted for a different cloud and sorts nothing within your own. It is a type check, not an authorization decision. Every bit of separating power in that policy sits in sub.
That is the right way round, whether or not it was chosen for that reason. Teams who copied the pattern ended up with a policy whose load-bearing condition is a claim their workflows cannot choose. Teams who went the other way, separating two privilege levels by audience alone, built the one configuration in which the attacker's single degree of freedom is also the gate.
What to do this quarter
Enumerate every IAM role whose trust policy names token.actions.githubusercontent.com, then apply three tests to each condition block.
- Count the condition keys that are not
:aud. If the answer is zero, any repository in any organization can assume that role by requesting the matching audience. GitHub's cloud-provider hardening guidance says you must define at least one condition, which is a floor rather than a target. - Check whether the remaining keys pin a repository or a workflow.
:repository,:repository_id, and a:subending in a branch ref pin the former. Only:job_workflow_refand:environmentpin the latter. Where a role is reachable from a repository that runs third-party actions, repository-level scoping means every workflow in that repository shares the credential. - Verify that no two roles differ only by audience. If a pair does, move the distinction onto
suborjob_workflow_refbefore anything else on this list.
Two boundary conditions, both from the same reference. AWS documents :job_workflow_ref as the reference path to the reusable workflow, for jobs that use one, so test 2 assumes privileged steps are routed through reusable workflows and gives you nothing where they are inline. And AWS notes that once the environment claim appears in a trust policy, an environment must be configured and supplied by the workflow, so adding :environment is a workflow change and not only a policy change. One more thing worth knowing before you plan the work: of the GitHub-specific keys, the reference marks none as available in the role session, while aud and sub are. They constrain the initial AssumeRoleWithWebIdentity call and nothing downstream of it.
Where sub does not already carry the fields you want to pin, that is what include_claim_keys is for. Change the subject template first, because a trust policy is only as expressive as the claim it reads.
What would have to be true for this to be wrong
That audience-side control is defense in depth rather than the control is a claim about your configuration, and test 3 checks it. Find roles separated by audience alone and the conclusion inverts: for that estate the audience genuinely is the access control, the missing constraint is your bug rather than your trust policy's, and waiting for the feature beats rewriting conditions. Find none and the request to GitHub is a hardening improvement you can adopt whenever it lands, while the trust policy is the part you own today.
This is the seam that why enterprise AI doesn't ship describes, in its least glamorous form: integration, where the component works and the failure is in the auth and audit trail between two systems that never agreed on what a credential means. Neither vendor shipped a bug. GitHub issues a token whose audience the caller selects; AWS honours the policy its customer wrote. The gap is the contract between them, and a contract is not something either one can ship alone.
Turn this into a plan for your team.
One week, fixed fee: a working session with your team, a prioritized use-case backlog, and an ROI model for the opportunities worth chasing.