repo:my-org/my-repo:* lets a stranger's pull request into your AWS account
Federating GitHub Actions into AWS with OIDC is the right move: it replaces a long-lived
AWS_SECRET_ACCESS_KEY in your repository secrets with a short-lived token minted per run.
The catch is that the entire security boundary then lives in one place — the role's trust
policy — and the ways that document goes wrong all look approximately correct while you are
reading it.
When a workflow requests a token, GitHub signs a set of claims about the run. Two of them appear in almost every trust policy, and they do very different jobs:
aud |
The audience — sts.amazonaws.com. This is the same value for
every AWS/GitHub OIDC integration on the planet. It says the token is meant for AWS STS.
It says nothing whatsoever about who issued it. |
|---|---|
sub |
The subject — repo:<owner>/<repo>:<context>. This is the only
claim that identifies the repository and the circumstances of the run. |
So a trust policy that carefully pins aud and says nothing about sub is
assumable by a GitHub Actions workflow in any repository on GitHub. Anyone can create a
repository, add a workflow, and request a token with that audience. This is the single highest-severity
version of the mistake, and it is easy to ship because the policy visibly contains a condition block and
visibly names GitHub's issuer.
The context is the part after the repository, and it is one of:
ref:refs/heads/main | a run on that branch |
|---|---|
ref:refs/tags/v1.2.3 | a run for that tag |
environment:production | a job targeting that environment — the only context that can carry required reviewers |
pull_request | a run triggered by a pull request, including one opened from a fork |
Which is why repo:my-org/my-repo:* is not the harmless "just this repo" shorthand it looks
like. It matches all four rows. On a public repository — or any repository that accepts pull requests
from forks — that includes a workflow run proposed by someone with no write access and no review. If the
role can write to production, so can they.
// Looks locked down. Is not.
"Condition": {
"StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" },
"StringLike": { "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:*" }
}
The fix is to name the context you actually deploy from:
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
}
}
Note that this uses StringEquals, not StringLike. When you are pinning an exact
value, an equality operator is both clearer and immune to a wildcard sneaking in later.
IAM only treats * and ? as wildcards under the StringLike family of
operators. Under StringEquals they are ordinary characters. So this:
"StringEquals": { "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:*" }
is asking for a subject claim whose last character is literally an asterisk. No GitHub token ever carries one, so the condition never matches and the role cannot be assumed at all. This fails closed. It is a broken deployment pipeline, not an exposure — and it is worth calling out separately, because a checker that reports it as "wildcard in sub: critical" sends you to tighten a policy that is already refusing everybody, while the actual symptom is a deploy job failing with an unhelpful STS error.
IAM grants access when any value in a condition list matches. So a policy like this is exactly as permissive as its last line, no matter how carefully the first three are written:
"StringLike": {
"token.actions.githubusercontent.com:sub": [
"repo:my-org/my-repo:ref:refs/heads/main",
"repo:my-org/my-repo:ref:refs/heads/staging",
"repo:my-org/my-repo:environment:production",
"repo:my-org/my-repo:*"
]
}
Read top-down, that list is reassuring for three lines. This is a common shape in practice because entries get appended over time — someone adds a temporary wildcard to unblock a release and it stays.
StringNotEquals and StringNotLike exclude what they list and permit everything
else. A condition that excludes one repository still admits every other repository on GitHub, so a
negated operator on sub is not a narrower pin — it is an open door with one name struck off.
The trust policy is the door, not the room. Even a perfectly pinned policy leaves questions it cannot
answer: what the role is permitted to do once assumed, who can push to the branch you pinned, and whether
the environment you named actually has required reviewers configured. A role pinned to
ref:refs/heads/main in a repository where any contributor can push to main is
reachable by any contributor. And an organisation can customise which claims its OIDC issuer includes, in
which case a policy written against the customised claim will legitimately not match the default shape
described here.
This is evidence about a specific set of conditions, not a full security assessment — see the checker and its disclaimer for what it does and doesn't cover. The checker applies every rule on this page to a policy you paste, in your browser, with nothing uploaded, and ships the same classifier as a zero-dependency CLI you can run as a CI gate.