Skip to content
ExponaBLOG
AWS FOR DEVELOPERS · DAY 067 / 100

Replace access keys with OIDC: a AWS guide for developers

The most valuable CI/CD security upgrade.

Expona Admin14 min read

Replace access keys with OIDC

Most teams I've worked with still rotate AWS access keys in their CI/CD pipelines, treating them like regular credentials. The problem: keys live in your repository secrets, survive longer than they should, and if leaked, give attackers persistent AWS access. Today I'm walking you through replacing those long-lived keys with OpenID Connect (OIDC)—a mechanism that generates short-lived, single-use credentials per CI run, locked to your specific repository and branch.

Why OIDC matters for CI/CD

Five years ago, I had a contractor's leaked AWS key sitting in a GitHub repo for two weeks. By the time we rotated it, someone had spun up EC2 instances and run a cryptominer. The fix took hours; the damage cost thousands.

OIDC eliminates that risk by removing the long-lived key from your secrets entirely. Instead of storing an AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, your CI/CD platform (GitHub Actions in this walkthrough) proves its identity to AWS using a signed token. AWS verifies that token came from your repo, issues temporary credentials good for 15 minutes, and revokes them automatically. No keys to leak. No rotation overhead. No persistent blast radius.

The difference in practice is stark. With static keys, a compromised credential can be used hours, days, or weeks after initial exposure. I've seen attackers use old leaked keys months later when they knew rotations wouldn't catch them. With OIDC, your window of exposure is the workflow duration—typically under five minutes. Even if someone intercepts the temporary credential during a build, it's worthless after the workflow completes.

How OIDC authentication works end-to-end

Understanding the flow helps you debug issues and appreciate why OIDC is secure by design.

When your GitHub Actions workflow runs, the runner requests a signed JWT token from GitHub's OIDC provider. This token contains metadata about the workflow: your repository name, branch, commit SHA, workflow path, and which GitHub Actions step requested it. GitHub cryptographically signs this token with a private key that AWS can verify.

Your workflow then exchanges this token with AWS STS (Security Token Service) using the AssumeRoleWithWebIdentity API. AWS validates the token's signature against GitHub's public certificate, checks the token's claims (repository, branch, audience) against the trust policy you defined, and if everything matches, issues temporary AWS credentials—typically a set of access key ID, secret access key, and session token valid for 15 minutes.

These temporary credentials are environment variables during your workflow, used to authenticate any AWS API calls (ECR push, CloudFormation, Lambda deploy), and automatically cleared after the workflow ends or times out. No credentials persist. No manual cleanup needed.

On a ride-hailing platform I worked on, this reduced our incident response time from "find and rotate the key" to "the credentials already expired." We stopped tracking CI credentials entirely in our security audits.

Create an IAM OIDC provider for GitHub

The first step is telling AWS to trust GitHub Actions as an identity provider. This is a one-time setup done at the AWS account level.

Log into your AWS Console and navigate to IAM > Identity providers. Click Create provider:

  • Provider type: OpenID Connect
  • Provider URL: https://token.actions.githubusercontent.com
  • Audience: sts.amazonaws.com

Click Get thumbprint—AWS will validate GitHub's certificate and display the thumbprint automatically. Then create the provider.

Or do it via AWS CLI (cleaner for Infrastructure-as-Code and repeatable across accounts):

bash
aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1

The thumbprint is GitHub's SSL certificate thumbprint and changes rarely—check the GitHub documentation if you need the current value. You'll get back an ARN like arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com—save this, you'll reference it in the trust policy.

You now have an OIDC provider that GitHub can authenticate through. The next step is creating an IAM role that trusts this provider for your specific repository. This is where the security actually lives.

Trust policy restricted to your repo and branches

The power of OIDC is in the trust policy. You're not just trusting GitHub broadly—you're trusting only your repository, specific branches, environments, and even specific workflow paths. This granularity is what makes OIDC dramatically more secure than "give every GitHub org member the same AWS key."

Create an IAM role called something like github-actions-role. In the trust policy, attach this policy document:

bash
{
  "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"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/main"
        }
      }
    }
  ]
}

Break down that condition:

  • token.actions.githubusercontent.com:aud must equal sts.amazonaws.com—this is boilerplate and prevents token misuse for other services.
  • token.actions.githubusercontent.com:sub matches the pattern repo:your-org/your-repo:ref:refs/heads/main—this locks the role to only your repository and the main branch. An attacker with credentials from a different branch or repo cannot assume this role.

Depending on your workflow, you might need variations. If you want to allow multiple branches (for staging and production), use wildcards:

bash
"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/*"

Or lock to environment names, which is powerful for multi-environment deployments:

bash
"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:environment:production"

Or restrict to pull requests only (useful for CI that doesn't require AWS access on merge):

bash
"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:pull_request"

I've also seen teams create separate roles per environment:

  • github-actions-staging-role with condition repo:your-org/your-repo:environment:staging and broad permissions.
  • github-actions-prod-role with condition repo:your-org/your-repo:environment:production and restricted to CloudFormation updates only.

On a trading platform, this prevented developers from accidentally deploying to production via a staging workflow—the token from staging couldn't assume the production role even if someone smuggled the role ARN into their script.

Attach your deployment policies to this role—whatever permissions your CI/CD needs (ECR push, CloudFormation, Lambda updates, S3 bucket writes). Start with the minimal permissions and expand only as needed. Now update your GitHub Actions workflow to assume this role instead of using static keys.

Update your GitHub Actions workflow

In your GitHub Actions workflow, replace the old approach that injected static credentials:

bash
# OLD: Static keys in secrets (don't do this anymore)
- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v1
  with:
    aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
    aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    aws-region: us-east-1

With role assumption via OIDC:

bash
# NEW: OIDC-based role assumption
- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::ACCOUNT_ID:role/github-actions-role
    aws-region: us-east-1
    role-session-name: github-actions-${{ github.run_id }}

A few critical details:

  • Use v4 or later. Versions 1–3 don't support OIDC without additional configuration. Version 4 made it the default.
  • The role-session-name is for CloudTrail visibility. When you audit who deployed what, the session name includes the GitHub Actions run ID, making logs searchable and auditable. I've debugged deployment failures by finding the exact workflow run ID in CloudTrail.
  • The role ARN is public. Unlike access keys, there's no secret here. The role can only be assumed via the OIDC token, and that token's claims (repo, branch, etc.) are checked server-side by AWS.

Here's a realistic example workflow for a NestJS backend deployment:

bash
name: Deploy to production
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v3
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-role
          aws-region: us-east-1
          role-session-name: deploy-${{ github.run_id }}
      - name: Push to ECR
        run: |
          aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin 123456789012.dkr.ecr.us-east-1.amazonaws.com
          docker build -t my-backend:${{ github.sha }} .
          docker tag my-backend:${{ github.sha }} 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-backend:latest
          docker push 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-backend:latest
      - name: Update ECS service
        run: |
          aws ecs update-service \
            --cluster production \
            --service my-backend \
            --force-new-deployment

Notice the permissions block at the top—that's required. id-token: write allows the GitHub Actions runner to request the OIDC token. Without it, the assume-role step fails silently.

Under the hood, when this workflow runs:

  1. GitHub Actions requests a signed token from its OIDC endpoint that includes this workflow's metadata (repo, branch, commit, run ID).
  2. The configure-aws-credentials action exchanges that token with AWS STS for temporary credentials (15-minute TTL by default).
  3. Those credentials are injected into the environment for the remaining steps.
  4. Any AWS CLI or SDK calls (ECR login, ECS update) use those temporary credentials.
  5. After the workflow completes or times out, the credentials expire and become unusable.

On a recent SaaS platform, this reduced our AWS credential management from a daily task (rotating keys monthly, auditing who had them) to a one-time setup. No more secrets sprawl, no more "who deployed what and when" confusion in the audit trail.

Short-lived credentials per run in practice

The security benefit is concrete. Let's say someone commits a sensitive file (a database password, an API key) to your GitHub repo alongside their workflow. An attacker clones the repo and extracts the static AWS access key from git history. With the old approach, they now have AWS access until key rotation happens—potentially hours or days later. Real incidents I've seen: keys left in commits for weeks, discovered by automated scanning with zero visibility into who used them.

With OIDC, that same attacker has the access key but cannot use it outside your GitHub Actions runner because it's: - Only valid for 15 minutes - Tied to the specific AWS account and role - Revoked automatically after the workflow ends - Paired with a session token that expires, so even if they grab both access key and secret, the session token is already gone

On a travel platform I worked on, we had a critical incident where a developer's machine was compromised. The attacker found the AWS access key in their shell history. With static keys, we'd have had to rotate immediately and audit all actions in CloudTrail. With OIDC, we checked the timestamps—the keys in their shell were older than our rotation cycle and already expired by the time we detected the compromise. Zero blast radius.

Handle multiple AWS accounts and environments

Most production systems use multiple AWS accounts (dev, staging, prod). With OIDC, you create separate roles per account and trust policy per environment.

In your dev AWS account, create github-actions-dev-role with this trust policy:

bash
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::DEV_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:your-org/your-repo:environment:development"
        }
      }
    }
  ]
}

In your prod AWS account, create github-actions-prod-role with environment set to production.

In your GitHub repo, define environments in your Settings > Environments with optional branch protections. Then in your workflow:

bash
jobs:
  deploy-dev:
    environment: development
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v3
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111111111111:role/github-actions-dev-role
          aws-region: us-east-1
      - run: ./deploy.sh
  
  deploy-prod:
    environment: production
    needs: deploy-dev
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v3
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::222222222222:role/github-actions-prod-role
          aws-region: us-east-1
      - run: ./deploy.sh

Each job assumes a different role in a different account. The token's claims (environment: production) are verified server-side. A compromised dev token cannot assume the prod role. On a CRM platform with strict compliance requirements, this setup passed our security audit in minutes—no static keys to worry about per environment.

Delete old CI access keys

Once your workflow is live and tested with OIDC, hunt down and delete the old static keys. This is non-negotiable—keeping old keys around defeats the entire point.

In the AWS Console, navigate to IAM > Users. Check for any service accounts or roles created for CI/CD (often named something like github-ci-user, cicd-deployer, or github-actions-user). Click into each user, navigate to Security credentials, and delete their access keys. If the user has no other purpose, delete the user entirely.

In GitHub, navigate to your repository Settings > Secrets and delete AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY. Search your repository's git history to make sure these keys don't appear in old commits:

bash
git log --all --full-history -S "AKIA" -- | head -20

If old keys are in history, you'll need to do a rebase or use a tool like git-filter-branch or BFG to scrub them. Consider all machines that cloned this repo compromised if the keys were ever exposed.

Verify your workflow still passes after deleting old keys. If it does, you're done—no more static AWS credentials in your CI pipeline.

I recommend auditing your entire AWS account for unused service accounts on a quarterly basis:

bash
aws iam list-users --query 'Users[*].[UserName, CreateDate]' --output table

Cross-reference those users against your current CI/CD platforms and active deployments. For any user without recent access key activity, check CloudTrail to see when they were last used:

bash
aws cloudtrail lookup-events --lookup-attributes AttributeKey=Username,AttributeValue=github-ci-user --max-results 1

Delete any that haven't been touched in 90+ days. On a platform I managed, this audit revealed 34 old service accounts from deprecated CI systems—a real security hole that would've been invisible without discipline.

Troubleshooting common issues

When OIDC authentication fails, the error messages from GitHub Actions can be cryptic. Here are the most common problems and fixes:

Error: "User: arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com is not authorized to perform: sts:AssumeRoleWithWebIdentity"

This means the trust policy is too restrictive or missing the sts:AssumeRoleWithWebIdentity action. Double-check your trust policy includes the exact string in the Condition. AWS's condition matching is strict—repo:your-org/your-repo:* does not match repo:your-org/different-repo:*.

Error: "Audience in token is invalid"

The trust policy's audience must be exactly sts.amazonaws.com. Nothing else. If you see this, verify your OIDC provider was created with the correct audience.

Error: "OpenID Connect token is invalid"

The GitHub Actions runner couldn't get a valid token. Ensure the permissions block in your workflow includes id-token: write. Without it, the runner can't request the OIDC token.

Debugging tip: Use assume-role with explicit debugging

bash
- name: Debug assume-role
  run: |
    aws sts get-caller-identity
    aws sts assume-role \
      --role-arn arn:aws:iam::ACCOUNT_ID:role/github-actions-role \
      --role-session-name debug-${{ github.run_id }} 2>&1 | head -20

The error message will tell you exactly which condition failed. Save the OIDC token locally and decode it to see what claims GitHub issued:

bash
- name: Debug token
  run: |
    OIDC_TOKEN=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
      "$ACTIONS_ID_TOKEN_REQUEST_URL" | jq -r '.token')
    echo "$OIDC_TOKEN" | cut -d. -f2 | base64 -d | jq .

This shows you the token's subject (sub), audience (aud), and other claims. Cross-reference against your trust policy to find the mismatch.

Common mistakes that hurt security

  • Trust policy too permissive: Using repo:your-org/*:* lets any repository in your org assume the role. A compromised repo can now deploy to production. Restrict to specific repos and branches. If your organization has 20 repos, 19 of them don't need production AWS access—don't give it to them.
  • Forgetting to update the GitHub Actions version: aws-actions/configure-aws-credentials@v1 or v2 don't properly support OIDC role assumption. Use v4 or later. Check the official docs before upgrading to ensure OIDC is still the default mechanism.
  • Testing OIDC only after deleting old keys: Keep old keys for a week after enabling OIDC. Verify logs that the new workflow is actually using the role via CloudTrail or by checking the AWS credentials used in your deployment. Then delete old keys. I've seen teams delete keys, then realize OIDC was misconfigured and have to recreate them in a panic.
  • Not setting a role session name: Add role-session-name to your workflow for better CloudTrail visibility. Without it, all GitHub Actions assume-role calls look identical in logs. With a session name like github-actions-${{ github.run_id }}, you can trace exactly which workflow run deployed what.
  • Mixing OIDC with remaining static keys: Don't use OIDC for some workflows and keep static keys for others "just in case." This creates a false sense of security and complicates audits. Go all-in on OIDC or don't bother.
  • Not restricting to specific environments: Even within a single repo, use GitHub environments to separate dev, staging, and production deployments. A misconfigured staging workflow that assumes the prod role is a disaster waiting to happen.
  • Ignoring the permissions block: New workflows often omit permissions: { id-token: write, contents: read } because they're boilerplate. But without it, the token request fails silently, and the workflow falls back to whatever credentials exist (often nothing, causing a confusing error 10 steps later).

Key takeaways

  • OIDC replaces long-lived AWS access keys with short-lived, per-run credentials that expire automatically. A leaked OIDC credential is worthless after 15 minutes; a leaked static key is dangerous for months.
  • Set up an IAM OIDC provider pointing to GitHub's token endpoint once per AWS account. This is infrastructure-level setup that doesn't change often.
  • Create a role with a restrictive trust policy tied to your specific repository, branches, and environments. The trust policy is where security happens—make it granular.
  • Update your GitHub Actions workflow to assume the role via aws-actions/configure-aws-credentials@v4 with the permissions: { id-token: write } block. This is the application-level change that developers interact with.
  • Delete old service account keys and audit CloudTrail logs to confirm the new workflow is using OIDC-issued credentials.
  • This is a one-time 30-minute setup per AWS account that removes ongoing key rotation overhead, simplifies audits, and virtually eliminates the blast radius of credential compromise in CI/CD.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles