Why 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.

The sub claim is the only thing that says "which repository"

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 segment decides whether forks are included

The context is the part after the repository, and it is one of:

ref:refs/heads/maina run on that branch
ref:refs/tags/v1.2.3a run for that tag
environment:productiona job targeting that environment — the only context that can carry required reviewers
pull_requesta 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.

The trap that runs the other way: a literal asterisk

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.

In a list of values, the widest one wins

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.

One more: a negated operator does not pin anything

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.

What this does not cover

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.

← Back to the checker