Two bugs that broke GitHub Actions → AWS OIDC (and no tutorial mentions either)
Wanted a GitHub Actions workflow to spin up a spot EC2 instance, build a Linux app on it, upload the artifacts to S3, and terminate the instance. No stored AWS keys, so OIDC federation was the obvious choice: create an IAM role, trust GitHub's OIDC provider, done.
Followed the standard pattern everyone links to. It didn't work. Two separate bugs, both with the exact same error message, both invisible unless you go looking for them.
The error
Error: Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentity
Trust policy looked correct:
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": ["repo:OWNER/REPO:*"]
}
}
}
Identity provider existed, audience was sts.amazonaws.com, role ARN in the GitHub secret was correct, no trailing whitespace. Everything on paper matched every tutorial out there, including AWS's own docs. Still denied.
Bug 1: the sub claim isn't what you think it is
Instead of guessing further, I stopped trusting the trust policy and went and read the actual JWT GitHub was sending. You can pull it mid-workflow:
- name: debug OIDC token claims
run: |
IDTOKEN=$(curl -sSL -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r '.value')
echo "$IDTOKEN" | cut -d. -f2 | base64 -d | jq .
The sub claim came back as:
"sub": "repo:OWNER@10000000/REPO@20000000:ref:refs/heads/main"
Not repo:OWNER/REPO:ref:refs/heads/main, the format every tutorial assumes and every StringLike condition online is written against. This account has GitHub's "immutable IDs" OIDC subject-claim feature turned on, which embeds the numeric owner ID and repository ID directly into sub instead of the human-readable names. The point of the feature is that the trust relationship survives a repo rename or org transfer, since names change but the numeric IDs don't. Nice idea. Completely undocumented in the trust-policy examples I found, so the wildcard pattern repo:OWNER/REPO:* simply never matched, and AWS returned the same generic auth-denied error either way, no hint about which specific condition failed.
Fix was to just match reality:
"StringLike": {
"token.actions.githubusercontent.com:sub": [
"repo:OWNER@10000000/REPO@20000000:*"
]
}
Decode your own token before writing a single line of trust policy. It's the only way to know which sub format you're actually dealing with, and it takes thirty seconds.
Bug 2: GITHUB_TOKEN can't register a self-hosted runner
With OIDC fixed, the role assumed fine, VPC/subnet/AMI lookups worked, spot instance request went through. Then, immediately:
Error: GitHub Registration Token receiving error
HttpError: Resource not accessible by integration
This step used the ec2-github-runner action to register the fresh EC2 instance as a self-hosted GitHub Actions runner, using the default secrets.GITHUB_TOKEN. Turns out that endpoint, POST /repos/{owner}/{repo}/actions/runners/registration-token, categorically refuses the automatic GITHUB_TOKEN, no matter what you put in the workflow's permissions: block. It requires a real personal access token (or a GitHub App installation token) with Administration write access. This isn't a permissions-block oversight you can fix from YAML; it's a hard API restriction.
Fix: fine-grained PAT, scoped to the one repo, Administration set to Read and write, stored as its own secret, used only for the runner start/stop steps instead of GITHUB_TOKEN.
Takeaway
Both bugs produced generic, unhelpful errors. Both were invisible from reading the trust policy or the workflow YAML alone. Both were solved the same way: stop reasoning about the config in the abstract and go read what's actually being sent and what the API actually requires, instead of assuming the tutorial's world matches yours.