If your GitHub Actions pipeline deploys to AWS, there's a good chance you've seen (or written) this:
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
It works. It's also a quiet liability sitting in your repo settings.
There's a better way: no static keys, no rotation headaches, no credentials sitting in GitHub Secrets. Let's look at why the old way hurts, and how OIDC fixes it.
Index
- The problem with static AWS credentials
- What is AWS OIDC?
- Heads up: GitHub just changed the token format
- Build it with Terraform
- The workflow
- Debugging: see what GitHub actually sends
- Why this is secure
- Wrapping up
The problem with static AWS credentials
A traditional workflow looks like this:
GitHub Actions
|
| AWS_ACCESS_KEY_ID
| AWS_SECRET_ACCESS_KEY
v
AWS
Those two values are long-lived credentials. They don't expire when the job ends. They don't care which repo, branch, or machine uses them. Whoever holds them is your pipeline, today, next month, and next year.
Even when they're stored "securely" as GitHub Secrets, you still have to:
- Rotate them periodically (and remember to)
- Manage their lifecycle across every repo that uses them
- Make sure they never leak, whether in logs, forks, a pasted screenshot, or a compromised dependency
- Revoke them when a project or teammate is gone
- Keep the IAM user's permissions tight, forever
Think about what a leak looks like: a key copied into a chat, printed by a debug step, or lifted by a malicious action. It works from any laptop on the planet until someone notices. Many keys never get rotated at all.
Here's the thing, though: a CI/CD pipeline doesn't need permanent credentials. It needs credentials that exist only while the pipeline is running.
That's exactly what OIDC gives you.
What is AWS OIDC?
OpenID Connect (OIDC) lets GitHub Actions prove its identity to AWS and receive temporary credentials, with no stored AWS keys.
GitHub
|
GitHub Actions
|
| OIDC Token
v
AWS IAM / STS
|
| AssumeRoleWithWebIdentity
v
IAM Role
|
| Temporary Credentials
v
AWS Resources
GitHub never hands you an access key to manage. Instead, for every workflow run, it generates a signed OIDC token that says who is asking: which repo, which branch, which run. AWS verifies the signature, checks your role's trust policy, and returns credentials that expire on their own (one hour by default).
| Static access keys | OIDC |
|---|---|
| Stored in GitHub Secrets | Nothing stored |
| Valid until rotated (often never) | Expire in about an hour |
| A leak works from anywhere | Token only works for your repo and branch |
| Manual rotation | Nothing to rotate |
The API behind it is AssumeRoleWithWebIdentity. The name is historical (it was built for web and mobile logins), but it's the same call used for GitHub, GitLab, and EKS. The caller needs no AWS credentials at all, because the signed token is the proof.
Heads up: GitHub just changed the token format
This is the part that's breaking older tutorials. The sub (subject) claim in the token used to look like this:
repo:octo-org/octo-repo:ref:refs/heads/main
For repositories created after July 15, 2026, it now includes immutable numeric IDs:
repo:octo-org@123456/octo-repo@456789:ref:refs/heads/main
123456 is the owner ID and 456789 is the repo ID. The @ separator is used because it can't appear in a GitHub username or repo name.
- New repos get the new format automatically.
- Renamed or transferred repos after that date also move to it.
- Older repos keep the old format unless you opt in (Settings → Actions → OIDC).
- GitHub Enterprise Server isn't part of the rollout.
If your trust policy expects the old format and your repo sends the new one, AWS answers with Not authorized to perform sts:AssumeRoleWithWebIdentity. Most guides online still show the old format, so this catches a lot of people.
Why GitHub changed it
The old sub was built from names, and names can be reused:
- You own
alice/deploy-tools, and your AWS role trusts that exact name. - You delete or rename the repo.
- An attacker registers the same name and creates the same repo.
- Their workflows produce the same
subas yours, and your role accepts them.
Numeric IDs are assigned once and never reused. A recreated repo gets different IDs, so its sub no longer matches your trust policy.
Build it with Terraform
1. Get your IDs
gh api repos/YOUR_USERNAME/YOUR_REPO --jq '{repo_id: .id, owner_id: .owner.id}'
Or open https://api.github.com/repos/YOUR_USERNAME/YOUR_REPO in a browser (public repos) and read id and owner.id.
2. Variables
variable "github_username" { type = string }
variable "github_repo" { type = string }
variable "github_branch" {
type = string
default = "main"
}
variable "github_owner_id" {
type = string
default = ""
}
variable "github_repo_id" {
type = string
default = ""
}
variable "create_oidc_provider" {
type = bool
default = true
}
3. Provider and subject
resource "aws_iam_openid_connect_provider" "github" {
count = var.create_oidc_provider ? 1 : 0
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
}
data "aws_iam_openid_connect_provider" "github" {
count = var.create_oidc_provider ? 0 : 1
url = "https://token.actions.githubusercontent.com"
}
locals {
oidc_provider_arn = var.create_oidc_provider ? aws_iam_openid_connect_provider.github[0].arn : data.aws_iam_openid_connect_provider.github[0].arn
use_ids = var.github_owner_id != "" && var.github_repo_id != ""
github_sub = local.use_ids ? (
"repo:${var.github_username}@${var.github_owner_id}/${var.github_repo}@${var.github_repo_id}:ref:refs/heads/${var.github_branch}"
) : (
"repo:${var.github_username}/${var.github_repo}:ref:refs/heads/${var.github_branch}"
)
}
AWS allows only one GitHub OIDC provider per account, so set create_oidc_provider = false if yours already exists. The thumbprint can be omitted on AWS provider 5.81 or newer. The conditional builds the new format when you pass both IDs and falls back to the old one otherwise.
4. Trust policy, role, and permissions
data "aws_iam_policy_document" "trust" {
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [local.oidc_provider_arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = [local.github_sub]
}
}
}
resource "aws_iam_role" "github_actions" {
name = "github-actions-deploy"
assume_role_policy = data.aws_iam_policy_document.trust.json
}
resource "aws_iam_role_policy_attachment" "s3" {
role = aws_iam_role.github_actions.name
policy_arn = "arn:aws:iam::aws:policy/AmazonS3FullAccess"
}
output "github_actions_role_arn" {
value = aws_iam_role.github_actions.arn
}
output "allowed_subject" {
value = local.github_sub
}
The trust policy answers who can become the role. The policy attachment answers what the role can do. They're separate on purpose.
AmazonS3FullAccess is fine for a demo, but it allows every S3 action on every bucket. For real use, swap it for a policy scoped to the your need and the resource you need.
terraform init
terraform apply
terraform output
The workflow
Save the role ARN as a repository secret or variable named AWS_ROLE_ARN. Notice what's missing: no access key, no secret key.
name: OIDC Test
on:
workflow_dispatch: {}
permissions:
id-token: write # without this, no token, no login
contents: read
jobs:
OIDC-Test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ap-south-1
- run: aws sts get-caller-identity
The classic mistake is forgetting id-token: write.
Debugging: see what GitHub actually sends
If you hit Not authorized to perform sts:AssumeRoleWithWebIdentity, add this step before the AWS step:
- name: Print OIDC claims
run: |
TOKEN=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value)
payload=$(echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+')
while [ $(( ${#payload} % 4 )) -ne 0 ]; do payload="${payload}="; done
echo "$payload" | base64 -d | jq '{iss, aud, sub, repository}'
Compare the printed sub with terraform output allowed_subject, character for character. That's how I found my own bug: my token showed repo:USER@191673596/REPO@1400242638:... while my trust policy expected the old format. Adding the IDs fixed it instantly.
Other quick checks:
- Case: usernames and repo names are case-sensitive.
- Branch: a manual run uses whichever branch you picked in the dropdown.
-
Credentials could not be loaded:role-to-assumewas empty. Usesecrets.for secrets andvars.for variables. -
Assuming role with OIDCrepeated: the action is retrying a rejected call. The real error is on the line after.
Why this is secure
- Signed token: AWS verifies the signature, so it can't be forged.
- Audience check: tokens meant for other services are rejected.
-
Exact
submatch: only your repo and branch get in. - Immutable IDs: a recycled name can't impersonate you.
- Short-lived credentials: they expire on their own.
- Nothing stored: nothing to steal from settings, logs, or forks.
A few habits make it stronger. Never use wildcards like repo:OWNER/* in the sub. Use one role per purpose. Protect production with a GitHub Environment and required reviewers. And remember that every new repo needs its own IDs in its trust policy.
Wrapping up
Static keys in GitHub Secrets were always a compromise. With OIDC your pipeline gets credentials only when it runs, only for the repo and branch you chose, and they vanish on their own. Just watch the new subject format, because older tutorials won't match what your new repos send.
If you're still pasting access keys into secrets, try this on one repo this week. You'll wonder why you waited.
Sources: GitHub Docs (OIDC reference, "Immutable subject claims") and the GitHub Changelog announcement on immutable subject claims.
Top comments (0)