Lab: Configuring OIDC Workload Identity for GitHub Actions with AWS

Overview

If your GitHub Actions workflows authenticate to AWS using AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY stored as repository secrets, you have a serious security problem. Those long-lived credentials never expire on their own, can be exfiltrated by any workflow step (including third-party actions), and give attackers persistent access to your AWS account if compromised.

OpenID Connect (OIDC) federation eliminates this risk entirely. Instead of storing static AWS credentials in GitHub, your workflow requests a short-lived OIDC token from GitHub’s identity provider. AWS validates this token, verifies the claims (repository, branch, environment), and issues temporary credentials that expire in minutes. No secrets are stored anywhere — the trust relationship is established between GitHub’s OIDC provider and your AWS IAM role.

In this hands-on lab, you will:

  • Set up the insecure baseline (static AWS keys) so you understand what you are replacing
  • Create an OIDC identity provider in AWS that trusts GitHub Actions
  • Create an IAM role with a trust policy scoped to your specific repository and branch
  • Update your GitHub Actions workflow to use OIDC authentication
  • Implement branch-based and environment-based access controls
  • Audit OIDC authentication events in CloudTrail
  • Delete the old static credentials to complete the migration

By the end of this lab, your CI/CD pipeline will have zero stored AWS credentials, and every authentication event will be traceable to a specific repository, branch, commit, and workflow run.

Prerequisites

Before starting this lab, ensure you have the following:

  • AWS account with IAM administrative access (ability to create identity providers, roles, and policies)
  • GitHub account with a repository you control (free tier is sufficient)
  • AWS CLI v2 installed and configured locally (aws --version should return 2.x)
  • Terraform v1.5+ (optional but recommended — this lab provides both Console and Terraform instructions)
  • Basic understanding of IAM roles, trust policies, and GitHub Actions workflow syntax

Estimated time: 60–90 minutes

Environment Setup: The Insecure Baseline

Before implementing OIDC, let’s establish the insecure pattern you are replacing. This makes the security improvement concrete and gives you a working workflow to migrate.

Step 1: Create a Test Repository

Create a new GitHub repository called oidc-lab. Initialize it with a README and clone it locally:

gh repo create oidc-lab --public --clone
cd oidc-lab

Step 2: Store AWS Credentials as GitHub Secrets (The Insecure Way)

If you have an existing IAM user with programmatic access, store its credentials as GitHub secrets:

gh secret set AWS_ACCESS_KEY_ID --body "AKIAIOSFODNN7EXAMPLE"
gh secret set AWS_SECRET_ACCESS_KEY --body "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"

Why this is dangerous:

  • No expiration: These credentials are valid until manually rotated or deleted. If leaked, an attacker has indefinite access.
  • Broad exposure: Every workflow run, every fork (if public), and every third-party action in your workflow can read these secrets.
  • No granular audit trail: CloudTrail shows the IAM user, but not which repository, branch, or workflow triggered the API call.
  • Rotation burden: You must manually rotate these keys and update the GitHub secrets — a process that is often neglected.

Step 3: Create the Insecure Baseline Workflow

Create .github/workflows/deploy.yml with static credentials:

name: Deploy (Insecure - Static Keys)

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS Credentials (INSECURE)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1

      - name: Verify Identity
        run: aws sts get-caller-identity

      - name: List S3 Buckets
        run: aws s3 ls

Commit and push this workflow. Confirm it runs successfully — this is the baseline you will replace with OIDC.

Exercise 1: Create the OIDC Identity Provider in AWS

The first step in OIDC federation is telling AWS to trust GitHub’s identity provider. This is a one-time setup per AWS account.

Option A: AWS Console

  1. Open the IAM ConsoleIdentity providersAdd provider
  2. Select OpenID Connect
  3. For Provider URL, enter: https://token.actions.githubusercontent.com
  4. Click Get thumbprint (AWS fetches and validates the TLS certificate)
  5. For Audience, enter: sts.amazonaws.com
  6. Click Add provider

Option B: AWS CLI

# Get the thumbprint for GitHub's OIDC provider
# As of 2024, GitHub's thumbprint is managed by AWS and auto-verified
# You can retrieve it with:
THUMBPRINT=$(openssl s_client -servername token.actions.githubusercontent.com \
  -showcerts -connect token.actions.githubusercontent.com:443 < /dev/null 2>/dev/null \
  | openssl x509 -fingerprint -noout \
  | cut -d'=' -f2 \
  | tr -d ':' \
  | tr '[:upper:]' '[:lower:]')

aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list "$THUMBPRINT"

Option C: Terraform

resource "aws_iam_openid_connect_provider" "github" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]

  tags = {
    Name        = "GitHub Actions OIDC"
    Environment = "shared"
    ManagedBy   = "terraform"
  }
}

Note: AWS now automatically verifies GitHub’s OIDC provider thumbprint. The thumbprint value in the Terraform resource is required by the API but AWS will validate the certificate chain regardless of the value provided.

Verification

Confirm the provider was created:

aws iam list-open-id-connect-providers

You should see an ARN like:

arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com

Exercise 2: Create the IAM Role with Trust Policy

Now create an IAM role that GitHub Actions can assume via OIDC. The trust policy is the most critical part — it determines exactly which repositories, branches, and environments can assume this role.

Step 1: Create the Trust Policy

Save the following as trust-policy.json. Replace 123456789012 with your AWS account ID, and myorg/myrepo with your GitHub organization and repository:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012: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:myorg/myrepo:ref:refs/heads/main"
        }
      }
    }
  ]
}

Understanding the Trust Policy Fields

  • Principal.Federated — The ARN of the GitHub OIDC provider you created in Exercise 1. This tells AWS which identity provider to trust.
  • Action: sts:AssumeRoleWithWebIdentity — The specific STS action for OIDC federation. This is different from sts:AssumeRole (used for cross-account roles) or sts:AssumeRoleWithSAML (used for SAML federation).
  • Condition.StringEquals.aud — Validates the audience claim in the OIDC token. This must be sts.amazonaws.com to match what the configure-aws-credentials action sends.
  • Condition.StringLike.sub — Validates the subject claim. This is the most important security control. The subject contains the repository, ref type, and ref value. Using StringLike with wildcards allows flexible matching, while StringEquals requires an exact match.

Step 2: Create the IAM Role

aws iam create-role \
  --role-name github-actions-deploy \
  --assume-role-policy-document file://trust-policy.json \
  --description "Role for GitHub Actions OIDC deployment" \
  --max-session-duration 3600

Step 3: Attach a Minimal IAM Policy

Follow the principle of least privilege. For this lab, attach a policy that grants read-only access to a specific S3 bucket:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::my-deployment-bucket",
        "arn:aws:s3:::my-deployment-bucket/*"
      ]
    }
  ]
}
# Save the above as permissions-policy.json, then:
aws iam put-role-policy \
  --role-name github-actions-deploy \
  --policy-name S3ReadAccess \
  --policy-document file://permissions-policy.json

Terraform Equivalent

data "aws_iam_policy_document" "github_actions_trust" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRoleWithWebIdentity"]

    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.github.arn]
    }

    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:aud"
      values   = ["sts.amazonaws.com"]
    }

    condition {
      test     = "StringLike"
      variable = "token.actions.githubusercontent.com:sub"
      values   = ["repo:myorg/myrepo:ref:refs/heads/main"]
    }
  }
}

resource "aws_iam_role" "github_actions_deploy" {
  name                 = "github-actions-deploy"
  assume_role_policy   = data.aws_iam_policy_document.github_actions_trust.json
  max_session_duration = 3600

  tags = {
    Name      = "GitHub Actions Deploy"
    ManagedBy = "terraform"
  }
}

resource "aws_iam_role_policy" "s3_read" {
  name = "S3ReadAccess"
  role = aws_iam_role.github_actions_deploy.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "s3:GetObject",
          "s3:ListBucket"
        ]
        Resource = [
          "arn:aws:s3:::my-deployment-bucket",
          "arn:aws:s3:::my-deployment-bucket/*"
        ]
      }
    ]
  })
}

Verification

# Confirm the role exists and has the correct trust policy
aws iam get-role --role-name github-actions-deploy \
  --query 'Role.AssumeRolePolicyDocument' --output json

Exercise 3: Update the GitHub Actions Workflow

Now replace the static credentials workflow with OIDC authentication. This is the core migration step.

Step 1: Update the Workflow

Replace the contents of .github/workflows/deploy.yml with the following:

name: Deploy (Secure - OIDC)

on:
  push:
    branches: [main]

permissions:
  id-token: write   # Required for OIDC token request
  contents: read     # Required for actions/checkout

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS Credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          role-session-name: github-actions-${{ github.run_id }}
          aws-region: us-east-1

      - name: Verify Identity
        run: |
          aws sts get-caller-identity
          echo "Successfully authenticated via OIDC!"

      - name: List S3 Bucket Contents
        run: aws s3 ls s3://my-deployment-bucket/

Key Changes Explained

  • permissions: id-token: write — This is mandatory. It grants the workflow permission to request an OIDC token from GitHub’s identity provider. Without this, the configure-aws-credentials action cannot obtain a token.
  • permissions: contents: read — When you set any explicit permission, all other permissions default to none. You must explicitly grant contents: read for actions/checkout to work.
  • role-to-assume — The ARN of the IAM role created in Exercise 2. The action will call sts:AssumeRoleWithWebIdentity using the OIDC token.
  • role-session-name — A descriptive session name that appears in CloudTrail. Including the github.run_id makes each session traceable to a specific workflow run.
  • No aws-access-key-id or aws-secret-access-key — These fields are completely removed. The action detects that role-to-assume is set without static credentials and automatically uses OIDC.

Step 2: Push and Verify

git add .github/workflows/deploy.yml
git commit -m "Migrate to OIDC authentication"
git push origin main

Navigate to the Actions tab in your GitHub repository. You should see the workflow running. In the “Verify Identity” step, the output will look like:

{
    "UserId": "AROA3XFRBF23ZCEXAMPLE:github-actions-9876543210",
    "Account": "123456789012",
    "Arn": "arn:aws:sts::123456789012:assumed-role/github-actions-deploy/github-actions-9876543210"
}

Notice that the ARN shows assumed-role/github-actions-deploy — this confirms the workflow is authenticating via the OIDC role, not an IAM user.

Exercise 4: Restrict by Branch, Environment, and Tag

The trust policy’s sub claim is your primary access control mechanism. The subject claim from GitHub Actions follows a predictable format that encodes the repository, trigger type, and ref.

Common Subject Claim Patterns

Trigger Subject Claim Format
Push to branch repo:OWNER/REPO:ref:refs/heads/BRANCH
Pull request repo:OWNER/REPO:pull_request
Environment repo:OWNER/REPO:environment:ENV_NAME
Tag push repo:OWNER/REPO:ref:refs/tags/TAG

Restricting to Main Branch Only

This is the configuration from Exercise 2. Only workflows triggered by a push to main can assume the role:

"StringLike": {
  "token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:ref:refs/heads/main"
}

Restricting to a Specific Environment

GitHub Environments provide an additional layer of access control. When a job specifies an environment, the subject claim changes to include the environment name:

"StringLike": {
  "token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:environment:production"
}

Restricting to Tagged Releases

Allow only tagged releases (e.g., v1.0.0, v2.3.1) to assume the role:

"StringLike": {
  "token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:ref:refs/tags/v*"
}

Testing the Restrictions

Create a feature branch and push a workflow run:

git checkout -b feature/test-oidc-restriction
git commit --allow-empty -m "Test OIDC restriction"
git push origin feature/test-oidc-restriction

If your trust policy restricts to refs/heads/main, the workflow on the feature branch will fail with:

Error: Could not assume role with OIDC: Not authorized to perform
sts:AssumeRoleWithWebIdentity

AccessDenied: Not authorized to perform sts:AssumeRoleWithWebIdentity

Now push the same change to main:

git checkout main
git merge feature/test-oidc-restriction
git push origin main

The workflow on main succeeds. This demonstrates claim-based access control — the AWS trust policy evaluates claims embedded in the OIDC token to make authorization decisions. No credentials are involved; the token itself carries the authorization context.

Exercise 5: Per-Environment Roles

Production deployments should have stricter controls than staging. In this exercise, you create separate IAM roles for each environment, with different trust policies and permissions.

Step 1: Create GitHub Environments

In your repository, go to SettingsEnvironments:

  1. Create a staging environment (no protection rules)
  2. Create a production environment with:
    • Required reviewers: Add at least one team member
    • Deployment branches: Restrict to main only

Step 2: Create Staging IAM Role

The staging role trusts all branches in the repository:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012: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:myorg/myrepo:*"
        }
      }
    }
  ]
}
aws iam create-role \
  --role-name github-actions-staging \
  --assume-role-policy-document file://staging-trust-policy.json \
  --description "Staging deployment role - all branches"

Step 3: Create Production IAM Role

The production role trusts only the production environment on the main branch:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
          "token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:environment:production"
        }
      }
    }
  ]
}
aws iam create-role \
  --role-name github-actions-production \
  --assume-role-policy-document file://production-trust-policy.json \
  --description "Production deployment role - main branch + production environment only"

Step 4: Multi-Environment Workflow

Create a workflow that deploys to staging automatically and to production with manual approval:

name: Deploy Multi-Environment

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  deploy-staging:
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS Credentials (Staging)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-staging
          role-session-name: staging-${{ github.run_id }}
          aws-region: us-east-1

      - name: Deploy to Staging
        run: |
          aws sts get-caller-identity
          echo "Deploying to staging environment..."
          # Your staging deployment commands here

  deploy-production:
    runs-on: ubuntu-latest
    needs: deploy-staging
    environment: production
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS Credentials (Production)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-production
          role-session-name: production-${{ github.run_id }}
          aws-region: us-east-1

      - name: Deploy to Production
        run: |
          aws sts get-caller-identity
          echo "Deploying to production environment..."
          # Your production deployment commands here

When this workflow runs:

  1. The staging job runs immediately, assumes github-actions-staging, and deploys
  2. The production job waits for staging to complete and then pauses for manual approval
  3. A required reviewer approves the deployment in the GitHub UI
  4. The production job runs, assumes github-actions-production, and deploys

The production role’s trust policy uses StringEquals (not StringLike) with the exact subject repo:myorg/myrepo:environment:production. This means only jobs that declare environment: production can assume the production role — and GitHub Environments enforce that only main branch deployments with reviewer approval can use that environment.

Terraform for Both Roles

locals {
  github_oidc_arn = aws_iam_openid_connect_provider.github.arn
  github_repo     = "myorg/myrepo"
}

# Staging role - trusts all branches
resource "aws_iam_role" "github_actions_staging" {
  name = "github-actions-staging"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Federated = local.github_oidc_arn
        }
        Action = "sts:AssumeRoleWithWebIdentity"
        Condition = {
          StringEquals = {
            "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
          }
          StringLike = {
            "token.actions.githubusercontent.com:sub" = "repo:${local.github_repo}:*"
          }
        }
      }
    ]
  })
}

# Production role - trusts only the production environment
resource "aws_iam_role" "github_actions_production" {
  name = "github-actions-production"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Federated = local.github_oidc_arn
        }
        Action = "sts:AssumeRoleWithWebIdentity"
        Condition = {
          StringEquals = {
            "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
            "token.actions.githubusercontent.com:sub" = "repo:${local.github_repo}:environment:production"
          }
        }
      }
    ]
  })
}

Exercise 6: Verify and Audit

OIDC authentication creates detailed audit trails in AWS CloudTrail. Every AssumeRoleWithWebIdentity call is logged with the full set of GitHub OIDC claims.

Step 1: Query CloudTrail for OIDC Events

aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRoleWithWebIdentity \
  --max-results 10 \
  --query 'Events[].{Time:EventTime,Username:Username,Resources:Resources}' \
  --output table

Step 2: Examine the CloudTrail Event

A typical AssumeRoleWithWebIdentity event in CloudTrail contains the following key fields:

{
  "eventName": "AssumeRoleWithWebIdentity",
  "eventSource": "sts.amazonaws.com",
  "requestParameters": {
    "roleArn": "arn:aws:iam::123456789012:role/github-actions-deploy",
    "roleSessionName": "github-actions-9876543210"
  },
  "requestID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "responseElements": {
    "credentials": {
      "accessKeyId": "ASIAEXAMPLE...",
      "expiration": "Mar 23, 2026 2:00:00 PM"
    },
    "subjectFromWebIdentityToken": "repo:myorg/myrepo:ref:refs/heads/main",
    "provider": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
  },
  "additionalEventData": {
    "WebIdFederationData": {
      "federatedProvider": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com",
      "attributes": {
        "sub": "repo:myorg/myrepo:ref:refs/heads/main",
        "aud": "sts.amazonaws.com",
        "iss": "https://token.actions.githubusercontent.com",
        "repository": "myorg/myrepo",
        "ref": "refs/heads/main",
        "sha": "abc123def456...",
        "actor": "github-username",
        "workflow": "Deploy (Secure - OIDC)",
        "run_id": "9876543210"
      }
    }
  }
}

Notice the richness of this audit trail: you can see the exact repository, branch, commit SHA, the GitHub user who triggered the workflow, and the workflow name. This is far more detailed than what you get with static IAM user credentials.

Step 3: Create a CloudWatch Alarm for Failed AssumeRole Attempts

Failed AssumeRoleWithWebIdentity calls may indicate unauthorized access attempts or misconfigured trust policies. Set up a metric filter and alarm:

# Create a CloudWatch Logs metric filter for failed AssumeRoleWithWebIdentity
aws logs put-metric-filter \
  --log-group-name CloudTrail/DefaultLogGroup \
  --filter-name FailedOIDCAssumeRole \
  --filter-pattern '{ ($.eventName = "AssumeRoleWithWebIdentity") && ($.errorCode = "AccessDenied") }' \
  --metric-transformations \
    metricName=FailedOIDCAssumeRoleCount,metricNamespace=SecurityMetrics,metricValue=1

# Create a CloudWatch alarm that triggers when failures exceed threshold
aws cloudwatch put-metric-alarm \
  --alarm-name FailedOIDCAssumeRole \
  --alarm-description "Alert on failed OIDC AssumeRoleWithWebIdentity attempts" \
  --metric-name FailedOIDCAssumeRoleCount \
  --namespace SecurityMetrics \
  --statistic Sum \
  --period 300 \
  --threshold 3 \
  --comparison-operator GreaterThanOrEqualToThreshold \
  --evaluation-periods 1 \
  --alarm-actions arn:aws:sns:us-east-1:123456789012:security-alerts \
  --treat-missing-data notBreaching

Step 4: Audit Which Repos and Branches Have Accessed the Role

Use CloudTrail Lake or Athena to query historical access patterns:

# Using CloudTrail lookup to find all OIDC authentications in the last 24 hours
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRoleWithWebIdentity \
  --start-time $(date -u -d '24 hours ago' '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u -v-24H '+%Y-%m-%dT%H:%M:%SZ') \
  --end-time $(date -u '+%Y-%m-%dT%H:%M:%SZ') \
  --query 'Events[].CloudTrailEvent' \
  --output text | jq -r '.responseElements.subjectFromWebIdentityToken // "N/A"' | sort | uniq -c | sort -rn

This gives you a frequency count of which repositories and branches have authenticated via OIDC — essential for access reviews and compliance audits.

Exercise 7: Delete the Old Access Keys

This is the most important exercise in the entire lab. The migration from static credentials to OIDC is incomplete — and your security posture is not improved — until the old access keys are gone. As long as both authentication methods exist, an attacker can still use the static keys.

Step 1: Remove GitHub Secrets

Delete the old secrets from your GitHub repository:

gh secret delete AWS_ACCESS_KEY_ID --repo myorg/myrepo
gh secret delete AWS_SECRET_ACCESS_KEY --repo myorg/myrepo

Verify they are removed:

gh secret list --repo myorg/myrepo

You should no longer see AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY in the list.

Step 2: Deactivate the IAM Access Keys

Before deleting, deactivate the keys first. This gives you a rollback option if something breaks:

# List the access keys for the IAM user
aws iam list-access-keys --user-name github-deploy-user

# Deactivate (not delete) the access key
aws iam update-access-key \
  --user-name github-deploy-user \
  --access-key-id AKIAIOSFODNN7EXAMPLE \
  --status Inactive

Step 3: Verify the Pipeline Still Works

Trigger the OIDC workflow and confirm it completes successfully:

git commit --allow-empty -m "Verify OIDC-only authentication"
git push origin main

Check the Actions tab — the workflow should succeed using OIDC alone.

Step 4: Delete the Access Keys Permanently

After confirming the pipeline works without static keys (wait at least 24–48 hours to be safe), permanently delete the keys:

aws iam delete-access-key \
  --user-name github-deploy-user \
  --access-key-id AKIAIOSFODNN7EXAMPLE

If the IAM user was only used for GitHub Actions, delete the user entirely:

aws iam delete-user-policy --user-name github-deploy-user --policy-name DeployPolicy
aws iam delete-user --user-name github-deploy-user

The migration is now complete. Your pipeline authenticates exclusively via OIDC federation with zero stored credentials.

Cleanup

If this was a lab environment, clean up all resources:

# Delete IAM roles
aws iam delete-role-policy --role-name github-actions-deploy --policy-name S3ReadAccess
aws iam delete-role --role-name github-actions-deploy

aws iam delete-role-policy --role-name github-actions-staging --policy-name StagingPolicy
aws iam delete-role --role-name github-actions-staging

aws iam delete-role-policy --role-name github-actions-production --policy-name ProductionPolicy
aws iam delete-role --role-name github-actions-production

# Delete the OIDC provider
OIDC_ARN=$(aws iam list-open-id-connect-providers \
  --query "OpenIDConnectProviderList[?ends_with(Arn, 'token.actions.githubusercontent.com')].Arn" \
  --output text)
aws iam delete-open-id-connect-provider --open-id-connect-provider-arn "$OIDC_ARN"

# Delete the test repository (optional)
gh repo delete myorg/oidc-lab --yes

If you used Terraform, cleanup is simpler:

terraform destroy -auto-approve

Key Takeaways

  • OIDC federation eliminates stored credentials. No AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY in GitHub — the trust relationship is between GitHub’s identity provider and your AWS IAM role.
  • Short-lived credentials reduce blast radius. OIDC tokens and the resulting AWS session credentials expire in minutes, not months. A compromised token is useless after expiration.
  • Trust policies provide claim-based access control. The sub claim in the OIDC token encodes the repository, branch, and environment — use StringEquals and StringLike conditions to enforce granular access.
  • Per-environment roles enforce separation of duties. Staging and production should use different IAM roles with different trust policies and different permission levels.
  • CloudTrail provides complete audit trails. Every OIDC authentication event logs the repository, branch, commit, actor, and workflow — far richer than static credential auditing.
  • The migration is incomplete until old keys are deleted. Running OIDC in parallel with static keys gives you no security improvement. Delete the old credentials after verifying OIDC works.

Next Steps

Continue strengthening your CI/CD security posture with these related guides: