← All blogs
  • Openid Connect
  • Oidc Authentication
  • Aws
  • Github Actions
  • Github

I Replaced AWS Access Keys in GitHub Actions With OIDC

A walkthrough of replacing long-lived AWS access keys in GitHub Actions with OIDC and temporary deployment credentials.

Originally published on Medium ↗. Read the full article below.

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.

I Replaced AWS Access Keys in GitHub Actions With OIDC

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.