If you’ve ever set up GitHub Actions to deploy something to AWS, you’ve probably seen this pattern:
Create an IAM user.
Generate an access key.
Generate a secret key.
Put both into GitHub Secrets.
Hope you never accidentally expose them.
It works, but I wasn’t particularly happy with the idea of keeping a long-lived AWS credential around just so GitHub Actions could deploy my application.
So I switched the workflow to GitHub Actions OIDC.
The idea is pretty simple: GitHub proves its identity to AWS, AWS checks whether that repository is trusted, and if everything matches, AWS gives the workflow temporary credentials.
No AWS access key or secret key stored in GitHub.
Here’s how I set it up.

What the setup looks like
In my case, I wanted GitHub Actions to build a Docker image and push it to Amazon ECR.
The flow looks like this:
GitHub Actions
|
| OIDC token
v
GitHub OIDC
|
| AssumeRoleWithWebIdentity
v
AWS STS
|
v
IAM Role
|
v
Amazon ECR
There are three AWS pieces to configure:
- An OIDC identity provider
- An IAM role with a trust policy
- Permissions for that role
Then there are a couple of things to add to the GitHub Actions workflow.
1. Create the GitHub OIDC provider in AWS
In the AWS console, go to:
IAM → Identity providers → Add provider
Choose:
Provider type: OpenID Connect
For the provider URL:
https://token.actions.githubusercontent.com
For the audience:
sts.amazonaws.com
That’s it.
You should now have an identity provider in IAM that looks roughly like:
token.actions.githubusercontent.com
The important thing here is that we’re telling AWS:
“I trust GitHub’s OIDC provider as an identity provider.”
But that doesn’t mean every GitHub repository can now access your AWS account.
That’s what the IAM role’s trust policy is for.
2. Create an IAM role
Next, create a role.
For example:
gh-actions-role
The role is what GitHub Actions will eventually assume.
There are two different policies you need to understand here:
Trust policy
Who is allowed to assume this role?
Permissions policy
What can the role do after it has been assumed?
Keeping those two concepts separate makes IAM much easier to reason about.
3. Configure the trust relationship
This was the part that initially caught me.
A typical trust relationship looks something like:
{
"Version": "2012-10-17",
"Statement": [
{
"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",
"token.actions.githubusercontent.com:sub": "..."
}
}
}
]
}
The aud part is straightforward:
sts.amazonaws.com
The interesting part is sub.
That’s how we restrict the role to a particular GitHub repository and branch.
4. Don’t blindly copy the sub from an old tutorial
This is where I ran into trouble.
A lot of older GitHub Actions + AWS tutorials use a subject like:
repo:my-org/my-repo:ref:refs/heads/main
That format is still relevant for repositories that use it.
But GitHub introduced a new immutable subject format for repositories created after July 15, 2026.
The format looks like:
repo:OWNER@OWNER_ID/REPO@REPO_ID:ref:refs/heads/main
For example:
repo:my-org@123456/my-repo@789012:ref:refs/heads/main
The important point is:
Don’t guess this value. Use the subject GitHub actually sends.
GitHub documents the different OIDC subject formats in its OIDC documentation. (docs.github.com)
5. Restrict the role to your repository and branch
For example, suppose GitHub gives you:
repo:my-org@123456/my-repo@789012:ref:refs/heads/main
Then your trust policy can contain:
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:my-org@123456/my-repo@789012:ref:refs/heads/main"
}
}
Now the role isn’t simply saying:
*“*I trust GitHub.”
It’s saying:
“I trust this particular GitHub repository running from this particular branch.”
That’s a much better setup.
AWS also recommends restricting the GitHub OIDC sub claim rather than broadly trusting all GitHub repositories. (docs.aws.amazon.com)
6. Give the role only the permissions it needs
In my case, GitHub Actions only needed to push a Docker image to ECR.
So the role needs ECR permissions.
For example:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecr:BatchCheckLayerAvailability",
"ecr:CompleteLayerUpload",
"ecr:InitiateLayerUpload",
"ecr:PutImage",
"ecr:UploadLayerPart",
"ecr:BatchGetImage",
"ecr:GetDownloadUrlForLayer"
],
"Resource": "arn:aws:ecr:<REGION>:<ACCOUNT_ID>:repository/<REPOSITORY>"
},
{
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken"
],
"Resource": "*"
}
]
}
For example:
Region: ap-south-1
Repository: simplebank
So the repository ARN would be:
arn:aws:ecr:ap-south-1:<ACCOUNT_ID>:repository/simplebank
The important thing is that the permissions are scoped to the repository where possible.
AWS provides the ECR permissions required for pushing images in its documentation. (docs.aws.amazon.com)
7. Configure GitHub Actions
Now comes the GitHub side.
Your workflow needs permission to request an OIDC token:
permissions:
id-token: write
contents: read
The important line is:
id-token: write
Without that, GitHub won’t be able to request the token.
Then use the AWS credentials action:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::<ACCOUNT_ID>:role/gh-actions-role
aws-region: ap-south-1
The action handles the OIDC token and exchanges it with AWS STS for temporary credentials. (github.com)
8. My complete workflow
Here’s the workflow I ended up with:
name: Deploy to production
on:
push:
branches: ["main"]
jobs:
build:
name: Build and push Docker image
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
env:
ECR_REPOSITORY: simplebank
IMAGE_TAG: ${{ github.sha }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::<ACCOUNT_ID>:role/gh-actions-role
aws-region: ap-south-1
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build Docker image
env:
REGISTRY: ${{ steps.login-ecr.outputs.registry }}
run: |
docker build \
-t $REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG \
.
- name: Push Docker image
env:
REGISTRY: ${{ steps.login-ecr.outputs.registry }}
run: |
docker push $REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
One small thing to watch out for here:
If you use variables like:
$ECR_REPOSITORY
$IMAGE_TAG
make sure you’ve actually defined them.
I initially had them in the Docker commands without defining them, which is an easy mistake to make.
9. Why I’m using the Git commit SHA as the Docker tag
Instead of doing:
simplebank:latest
I’m using:
IMAGE_TAG: ${{ github.sha }}
So every image corresponds to a specific commit.
For example:
simplebank:81b1d9a90d429de1f224dbf1b86abd...
That might look ugly, but it’s extremely useful when debugging deployments.
You can look at an image and know exactly which Git commit produced it.
It also makes rollbacks much easier.
You can always add latest as a second tag if your deployment process needs it.
10. Debugging the “Not authorized” error
The error I got was:
Error: Could not assume role with OIDC:
Not authorized to perform sts:AssumeRoleWithWebIdentity
At first, I thought I had messed up the IAM permissions.
But there’s an important distinction:
The workflow hadn’t even reached ECR yet.
The failure was happening here:
GitHub
↓
OIDC
↓
AWS STS
↓
IAM Role ← failure
↓
ECR
So changing ECR permissions wouldn’t have fixed it.
The problem was the IAM trust relationship.
Specifically, the sub in my trust policy didn't match the sub GitHub was actually sending.
Once I changed it to the new format:
repo:<OWNER>@<OWNER_ID>/<REPO>@<REPO_ID>:ref:refs/heads/main
the role assumption worked.
That was the missing piece.
A simple way to think about the trust policy
Think of the GitHub OIDC token as an ID card.
It contains information such as:
Who issued this?
→ GitHub
Who is this token intended for?
→ sts.amazonaws.com
Which repository is this?
→ my-org/my-repo
Which branch?
→ main
AWS checks those claims against the IAM trust policy.
If the values don’t match, AWS says:
Nope. You aren't allowed to assume this role.
Once everything matches:
GitHub
↓
OIDC token
↓
AWS validates token
↓
IAM trust policy matches
↓
STS issues temporary credentials
↓
GitHub can use AWS
Things I’d check if it doesn’t work
If you’re setting this up yourself and get the same error, I’d check these before touching anything else:
GitHub workflow
permissions:
id-token: write
contents: read
AWS OIDC provider
https://token.actions.githubusercontent.com
Audience
sts.amazonaws.com
IAM action
sts:AssumeRoleWithWebIdentity
IAM principal
Make sure it points to:
arn:aws:iam::<ACCOUNT_ID>:oidc-provider/token.actions.githubusercontent.com
sub
This is the big one.
Make sure the value in AWS exactly matches the subject GitHub is actually issuing.
And if you’re using a GitHub Environment, check that too — the subject can use an environment-based format instead of a branch-based one. (docs.github.com)
The end result
Once everything is working, you have a pretty clean deployment path:
GitHub
|
| push to main
v
GitHub Actions
|
| OIDC
v
token.actions.githubusercontent.com
|
v
AWS STS
|
| AssumeRoleWithWebIdentity
v
gh-actions-role
|
| ECR permissions
v
Amazon ECR
|
v
simplebank:<SHA>
And there are no long-lived AWS access keys sitting in GitHub Secrets for this authentication flow.
Final thoughts
The biggest lesson for me was that the OIDC setup itself isn’t particularly complicated.
The confusing part is understanding what AWS is actually validating.
You need all of these pieces to line up:
GitHub OIDC provider
+
OIDC audience
+
OIDC subject
+
IAM trust policy
+
IAM permissions
+
GitHub workflow permissions
If one of them is wrong, the workflow fails.
And if you get:
Not authorized to perform sts:AssumeRoleWithWebIdentity
check the trust relationship first, especially the sub claim.
Also, be careful with older tutorials. GitHub’s newer immutable subject format means a sub copied from an older blog post may not match what your repository actually sends.
Once you get past that, the rest is surprisingly straightforward.
No access keys. No secret key rotation. Just GitHub OIDC + AWS IAM + temporary credentials.
That’s a much nicer way to build a CI/CD pipeline.