The Moment It Broke
"Welcome to my static site"! These words were like music to my eyes after I set up the AWS backend to host my static HTML website. Excited, I created the basic blog site with Astro and configured CI/CD in deploy.yml to start the deployment process and guess what came up!... "Welcome to my static site!" Something was wrong...
How I Debugged The Issue
I opened up my browser's console to debug if there were any errors, none. I cleared my cache, still my static site showed up. Then I went one level up, I checked in my S3 bucket, it wasn't there... my site never made it to S3. So, I went one more level above, my GitHub actions, and there it was Error: Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentity. I then went back to the AWS console and made sure I had github-deploy-site which was added from Terraform, to rule out Terraform Drift. Next I wanted to see what GitHub was actually sending, so I added a debug step right before the credentials step:
- run: echo "${{ github.repository }} / ${{ github.ref }}"
It printed Nasheik/KishanBuilds and refs/heads/main. Exactly what my trust policy expected. It was all correct, and at the time I did not realize that those two variables are not the sub claim. It was only after I asked Claude for help that I was pointed to a GitHub changelog from April 2026 that I hadn't found on my own. The subject portion of GitHub tokens had changed!
Part of what made this take so long is that the error tells you almost nothing. The same message covers a genuinely mismatched claim, a missing permissions: id-token: write block so no token is ever minted, and the format change I hit. Three unrelated problems at three different layers, one identical string.
The OIDC Process
When I push to the main branch, GitHub mints a short-lived JSON Web Token for each workflow run. It only exists for that run. The three claims that matter in this token are:
-
iss— the issuer, GitHub. -
aud— the audience,sts.amazonaws.com. -
sub— the subject, The repo and branch the workflow is running on.
The main part is the subject. The IAM role's trust policy has a condition on that sub string and gets used by STS to compare, if they do not match we get the error I got before. If they do match, you get the temporary credentials required to move forward, or, as in my case, upload files to S3 bucket.
The Change GitHub Made
"Repos created after July 15, 2026 get the new sub format by default." This was it, one line I found after Claude pointed me in the right direction. Source: https://github.blog/changelog/2026-04-23-immutable-subject-claims-for-github-actions-oidc-tokens/
Old: repo:OWNER/REPO:ref:refs/heads/BRANCH
New: repo:OWNER@OWNER-ID/REPO@REPO-ID:ref:refs/heads/BRANCH
What my trust policy expected:
repo:Nasheik/KishanBuilds:ref:refs/heads/main
What GitHub actually sent:
repo:Nasheik@MY-OWNER-ID/KishanBuilds@MY-REPO-ID:ref:refs/heads/main
This was made to prevent someone from inheriting your trust policy. Because the old format is just names, if you abandon your repo, someone else could claim and reproduce that sub string. With this change, that is not possible as your ID and your repo's ID are embedded into it.
The Fix
The fix for this was rather simple: change this condition
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:${var.github_repo}:ref:refs/heads/main"]
}
to
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = [
"repo:${var.github_repo}:ref:refs/heads/main",
"repo:${split("/", var.github_repo)[0]}@${var.github_owner_id}/${split("/", var.github_repo)[1]}@${var.github_repo_id}:ref:refs/heads/main",
]
}
This accepts either format for this specific repo, so I'm covered whichever way GitHub sends it, but the IDs are pinned, so a different repo would need its own values.
What It Taught Me
This tiny bug taught me two different lessons at once. First one, tutorials have timestamps, especially in a field like tech where everything changes so rapidly. You have to continuously learn, break, and re-learn. Second, when it comes to using different services, issues often stem from what one service sends and what the other service expects to receive, which is exactly what I thought my debug step was doing. Double checking that bridge will help catch bugs faster, as long as you're checking the real thing and not something that resembles it.
What's Next
Next up: moving Terraform itself into CI. Right now my site deploys through Actions while my infrastructure gets applied from my laptop, which means the only record of what exists in AWS is a file on my machine. Fixing that means remote state, a plan step on every PR, and working out how a pipeline gets permission to manage the very role it's authenticating with, a problem that starts looking a lot like the one mentioned in this post.
Top comments (0)