Baobaobaolin.com
date
entry
031
topic
infrastructure
rev
—

GitHub repos created after July 15, 2026 get two extra IDs in the OIDC sub

The same trust policy line works for an old repo and fails for a new one. The difference: GitHub now puts immutable owner and repo IDs in a new repo's sub.

When GitHub Actions deploys to AWS, I always use OIDC: the workflow asks GitHub for a short-lived identity token and trades it at STS for temporary credentials of an IAM role, so the repo holds no long-term keys. Besides pinning the identity provider and audience, the role's trust policy uses sub (subject) to pin which repo and branch may assume it, and that looks like repo:<owner>/<repo>:ref:refs/heads/main. I had copied that line from older projects many times without trouble.

On July 28, 2026 I set up a deploy role for a new repo, copied the same line, and three manually triggered deploys in a row failed at the step that fetches AWS credentials, with Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentity. The error doesn't say which condition failed. This note is about the cause: the sub that repo received looks different now. Four questions, in order: what the actual sub is, what GitHub changed, how to check before writing the policy, and why a wildcard doesn't rescue it.

The actual sub

The logs of the three failed jobs are still there. Each time, the configure-aws-credentials step printed Assuming role with OIDC 12 times before failing, taking 71, 96 and 55 seconds. The v4 version in use makes at most 12 attempts by default, counting the first (its action.yml describes retry-max-attempts as "By default it will retry 12 times"), with randomised exponential backoff in between. So a wrong trust policy meant waiting roughly one to one and a half minutes each time for the same sentence.

I added a temporary workflow that does one thing: request a token with audience sts.amazonaws.com, decode the middle payload segment, and print it. The original printed seven claims and decoded with base64 -d; below is a tidied version that prints three fields and decodes with Node's base64url (base64 -d implementations differ on unpadded base64url; on macOS, a segment missing its padding came out with the end cut off).

on: workflow_dispatch
permissions:
  id-token: write
jobs:
  claims:
    runs-on: ubuntu-latest
    steps:
      - shell: bash
        run: |
          set -o pipefail
          TOKEN=$(curl -fsS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -er '.value')
          printf '%s' "$TOKEN" | node -e '
            let s = ""; process.stdin.on("data", c => s += c);
            process.stdin.on("end", () => {
              const { sub, aud, iss } = JSON.parse(
                Buffer.from(s.split(".")[1], "base64url").toString("utf8"));
              console.log(JSON.stringify({ sub, aud, iss }, null, 2));
            });'

The sub it printed:

"sub": "repo:yuxue1213@18254762/<repo>@1314600209:ref:refs/heads/main"

(The repo is a client project, so I've masked its name; the rest is verbatim.) Both the owner and the repo name now carry an @ and a number. StringEquals compares the whole string, so without those two parts it doesn't match. (That workflow printed only selected claims, never the token itself, and I deleted it afterwards.)

What GitHub changed

GitHub announced this in its April 23, 2026 changelog, "Immutable subject claims for GitHub Actions OIDC tokens". Four points:

  • The default sub appends immutable owner and repo IDs to the names, for example repo:octocat@123456/my-repo@456789:ref:refs/heads/main.
  • Repos created after July 15, 2026 use the new format automatically; repos renamed or transferred after that date switch to it too.
  • Existing repos keep the old format as long as they aren't renamed or transferred after that date, unless you opt in through the repo or organization OIDC settings.
  • It applies only to github.com, not GitHub Enterprise Server.

The reason is that names can be recycled: under the old format, if someone else acquires the original owner/repo name (an account or organization name freed up, or a repo deleted and recreated), the new repo gets an identical sub for the same ref, while the cloud-side trust policy still trusts that name. With IDs added, a token from a different repo with the same name won't match.

The dates line up: gh api repos/yuxue1213/<repo> returns created_at 2026-07-28T06:01:39Z, after July 15. The old repo I copied from was created in May 2025, which is why the old format kept working there. Also, the same changelog was edited on June 10: the delimiter was originally documented as - and is now @. A tutorial that followed the announcement before June 10 and used - needs changing to the current @.

Check before writing the policy

GitHub's REST API has an endpoint that gives you a repo's sub prefix directly, no workflow run needed:

$ gh api repos/yuxue1213/<repo>/actions/oidc/customization/sub
{"use_default":true,"use_immutable_subject":true,
 "sub_claim_prefix":"repo:yuxue1213@18254762/<repo>@1314600209"}

$ gh api repos/yutuo-tech/homepage/actions/oidc/customization/sub
{"use_default":true,"use_immutable_subject":false,
 "sub_claim_prefix":"repo:yutuo-tech/homepage"}

Where use_immutable_subject is true, the prefix carries the IDs. Another repo of mine, created October 9, is also true, with prefix repo:yutuo-tech@210807359/taiwanrep@1411624306. The two numbers are the id returned by gh api users/<owner> (or orgs/<org> for an organization) and by gh api repos/<owner>/<repo>. The IDs themselves aren't secrets; anyone who can see the repo can look them up (the OIDC settings endpoint needs the matching permission for a private repo).

Write the trust policy condition from that prefix. My case is a push to main without an environment, so it ends in :ref:refs/heads/main; with an environment or a pull request event the second half of sub differs and needs checking separately. This is the Condition block of the trust policy:

"Condition": {
  "StringEquals": {
    "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
    "token.actions.githubusercontent.com:sub":
      "repo:yuxue1213@18254762/<repo>@1314600209:ref:refs/heads/main"
  }
}

Why a wildcard after the old prefix still doesn't match

When stuck, the instinct is to loosen the condition, say to StringLike with repo:yuxue1213/<repo>:*. That doesn't match either. A * only stands in where it is, and the new format differs in the middle: the pattern requires / right after yuxue1213, where the real string has @18254762/. Simulated with Python's fnmatch (it uses * and ? like IAM's StringLike, plus bracket classes that don't come into play here; REPO stands in for the repo name):

>>> from fnmatch import fnmatchcase
>>> sub = "repo:yuxue1213@18254762/REPO@1314600209:ref:refs/heads/main"
>>> fnmatchcase(sub, "repo:yuxue1213/REPO:*")
False
>>> fnmatchcase(sub, "repo:yuxue1213@18254762/REPO@1314600209:*")
True
>>> fnmatchcase("repo:yuxue1213x@999/REPO-evil@1:ref:refs/heads/main",
...             "repo:yuxue1213*/REPO*:*")
True

A wildcard that matches both formats needs a * after the names too, such as repo:yuxue1213*/REPO*:*. That lets back in exactly what the IDs were meant to keep out, and is looser than before: as the last line shows, a repo called REPO-evil under an account called yuxue1213x would also pass. If you need a transition period, give StringEquals an array for sub listing both full strings, old and new (multiple values for one key are ORed), and remove the old one once the switch is confirmed.

Before opting an existing repo into the new format, add the new prefix to the trust policy first, then switch. In the other order, tokens issued after the switch can't get STS credentials, and deploys fail from the next run. After a rename or transfer the prefix changes too, and the policy has to follow.

If you remember one thing

Don't copy a trust policy's sub from an old project; look up the repo's actual prefix with gh api repos/<owner>/<repo>/actions/oidc/customization/sub first. The three failures at the top added up to nearly four minutes of waiting for the same unexplained error, while the prefix was available from an API that doesn't need a workflow run; what's left is appending the right second half for how the job is triggered.

Revision history

  1. First published