If your GitHub Actions workflows use AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY stored as repository secrets, you have long-lived AWS credentials sitting in a system that GitHub employees can access (under their support policies), that any contributor with access to repository settings can read through log exposure, and that persist indefinitely unless someone manually rotates them. GitHub Actions OIDC federation with AWS STS is the correct replacement. This post walks through the full migration.
How GitHub Actions OIDC Works
GitHub Actions runs a built-in OIDC provider at https://token.actions.githubusercontent.com. When a workflow runs, GitHub can issue a JWT token signed by that provider. The token contains claims about the workflow: the repository, the branch, the triggering event, the environment, the run ID.
AWS IAM supports OIDC identity providers as trusted token issuers. You configure an IAM role with a trust policy that says "trust tokens from GitHub's OIDC provider, but only when those tokens satisfy these claim conditions." When a workflow requests AWS credentials, it exchanges the GitHub OIDC token for short-lived STS credentials — an access key, secret, and session token valid for a configurable duration (minimum 15 minutes, default 1 hour).
No stored AWS credentials anywhere. The workflow has no AWS secrets. It has a GitHub-issued JWT that proves "this is a run of workflow X in repository Y on branch Z," and AWS STS converts that proof into temporary credentials scoped to whatever the IAM role permits.
Step 1: Create the OIDC Identity Provider in AWS
You only need one OIDC provider per AWS account for GitHub Actions. If you already have it configured, skip this step. Check with:
aws iam list-open-id-connect-providers
If you don't see token.actions.githubusercontent.com, create it:
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 the fingerprint of the OIDC provider's root CA certificate. GitHub documents the current thumbprint. It has been 6938fd4d98bab03faadb97b34396831e3780aea1 for the primary root, though AWS validates via the provider URL in addition to the thumbprint.
Step 2: Create the IAM Role with a Trust Policy
This is where precision matters. The trust policy determines which GitHub Actions runs can assume this role. A policy that's too broad gives any repository in your organization the ability to assume the role. A policy that's too narrow will fail in ways that are painful to debug.
Here's a trust policy that restricts to a specific repository and specific branches:
{
"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:your-org/your-repo:ref:refs/heads/main"
}
}
}
]
}
The sub claim format is repo:OWNER/REPO:ref:refs/heads/BRANCH for branch-triggered runs, or repo:OWNER/REPO:environment:ENVIRONMENT_NAME for environment-scoped runs. Using StringLike with a wildcard (repo:your-org/your-repo:*) allows any trigger from that repository but not from other repositories. For a deploy role that should only be assumable from production deployments, pin to the specific environment:
"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:environment:production"
Attach the permission policy to this role with exactly what the workflow needs — typically ECR push access, S3 deploy bucket access, or whatever the specific workflow does. Avoid AdministratorAccess. Create one role per workflow or deployment stage, scoped to the minimum permissions for that workflow.
Step 3: Update the Workflow
The workflow needs two things: permission to request an OIDC token, and the aws-actions/configure-aws-credentials action to handle the exchange.
name: Deploy to Production
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy-prod
aws-region: us-east-1
role-session-name: GHActions-Deploy-${{ github.run_id }}
- name: Deploy
run: |
aws s3 sync ./dist s3://your-deploy-bucket/
The permissions: id-token: write block is required — it grants the workflow permission to request an OIDC token from GitHub's provider. Without this, the ACTIONS_ID_TOKEN_REQUEST_TOKEN environment variable isn't populated and the credential exchange fails.
The role-session-name parameter is optional but worth setting. Including the run ID makes your CloudTrail logs far more useful — you can correlate a specific AWS API call back to the exact workflow run that triggered it.
Step 4: Validate Before Removing Old Secrets
Run the workflow once with both the new OIDC path and the old AWS secrets present (don't remove the secrets yet). Confirm the workflow succeeds using OIDC. Check that the AWS session name in CloudTrail shows your role-session-name value. Then remove the old AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY secrets from repository settings.
After removing the secrets, verify that the workflow still succeeds without them. This is important — if you have other steps in the workflow that reference the secret names directly, they'll fail silently or with confusing errors rather than with "secret not found."
Scope Roles Per Workflow, Not Per Repository
A common mistake is creating one IAM role for all GitHub Actions in a repository and attaching broad permissions to it. This defeats the purpose. If a non-deploy workflow (say, a linter or a test runner) has the same role as the deploy workflow, then a compromised or malicious action in the test workflow can also trigger deploys.
The correct pattern: one IAM role per distinct workflow function, each with the minimum permissions for that function:
github-actions-test-REPO— read-only access to test fixtures in S3, nothing elsegithub-actions-build-REPO— ECR push access to the dev registrygithub-actions-deploy-prod-REPO— ECR pull, ECS update-service for the production cluster, pinned to theproductionenvironmentgithub-actions-deploy-staging-REPO— same as above but for staging, without the environment pin
Tying the production deploy role to a GitHub Environment (with required reviewers configured) means a deploy requires both a passing OIDC token for the production environment AND whatever approval gates you've configured in GitHub's environment protection rules.
Handling Cross-Account Access
For workflows that need access to resources in multiple AWS accounts, create the OIDC provider and trust-policy role in each target account, or use a hub-and-spoke model where a single role in a central account assumes roles in target accounts via cross-account role chaining.
With cross-account chaining:
- name: Configure AWS credentials (central account)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::CENTRAL-ACCT:role/gha-federation-hub
aws-region: us-east-1
- name: Assume production account role
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::PROD-ACCT:role/gha-deploy-prod
aws-region: us-east-1
role-chaining: true
The role-chaining: true flag tells the action to use the current session to assume the second role, rather than going back to the OIDC token. This is cleaner than re-requesting the GitHub token for each account and lets you maintain the OIDC trust policy only in the central account.
What This Doesn't Cover
OIDC federation with AWS STS solves the AWS credential storage problem for CI. It does not solve: credentials your workflows need for non-AWS resources (GitHub Packages PATs, Docker Hub credentials, Snyk tokens, Datadog API keys), secrets passed as environment variables within the workflow step itself, or artifacts that workflows produce that might contain secrets.
For non-AWS external credentials in CI, the options are: GitHub Secrets (still static, but scoped to environment and rotatable), HashiCorp Vault with JWT auth (same principle as OIDC federation — Vault trusts the GitHub JWT and issues short-lived secrets), or a purpose-built workload credential layer. The GitHub OIDC approach is AWS-specific, but the underlying principle — workload identity as the root of credential issuance, not stored secrets — generalizes. Any credential store that accepts an OIDC token as proof of identity can participate in this model.
That's where Aembit's model fits for production workloads. The same principle that lets GitHub Actions authenticate to AWS without stored credentials applies to Kubernetes pods authenticating to databases, third-party APIs, and any other resource that currently requires a stored secret. The workflow presents a token that proves its identity; the access layer evaluates policy and issues a short-lived credential. No secrets at rest, no rotation ceremonies, no exposure window beyond the credential TTL.