Eliminating AWS Long-Term Credentials with GitHub Actions OIDC — Trust Policy Design and Adapting to the 2026 sub Claim Change
For a while, I too kept AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in GitHub Secrets to run my deployment pipeline. I rationalized it to myself: "It's a Secret anyway, so it should be fine, right?" But honestly, those credentials were long-lived keys with no expiration — and once leaked, detection was slow and the damage was severe.
Using GitHub Actions OIDC, you can eliminate AWS long-lived credentials entirely. GitHub itself acts as an Identity Provider, issuing a signed JWT at workflow runtime, and AWS STS verifies it to hand out short-lived temporary credentials. With no long-lived keys, there are no keys to leak and no rotation to worry about.
That said, the setup isn't entirely straightforward. If you write the sub claim condition in the Trust Policy incorrectly, you can inadvertently create a serious vulnerability where any GitHub workflow can assume the role. And starting July 15, 2026, the OIDC token sub format itself changed for newly created repositories, causing existing pipelines to break unexpectedly. In this post, I'll cover everything from the correct setup procedure to handling the 2026 changes and designing for least-privilege.
Why This Approach Matters Now
Structural Limitations of Long-Lived Credentials
AWS long-lived credentials stored in GitHub Secrets come with several hard-to-avoid problems.
- They don't expire. IAM user access keys remain valid until explicitly deleted.
- Rotation is manual. It's easy to miss the cycle of issuing new keys, updating GitHub Secrets, and deleting old ones.
- They're exposed to supply chain attacks. There are real-world cases like the March 2025 tj-actions/changed-files incident, where a third-party Action was compromised and workflow environment variables and secrets were leaked through logs.
With OIDC, temporary credentials automatically expire after a default of 1 hour. The GitHub OIDC token itself also expires shortly after issuance, dramatically shrinking the window of opportunity for credential theft attacks.
The Context Behind Heightened Security Requirements
Datadog Security Labs published research finding that IAM roles configured with wildcard sub conditions (such as repo:*) exist in significant numbers in real-world environments. Both AWS and GitHub are moving toward strengthening detection and warnings for such misconfigurations, and correctly designing the Trust Policy itself — not just adopting OIDC — is what ultimately determines your actual security posture.
The OIDC Authentication Flow at a Glance
It looks complex when written out, but following the steps in order makes it clear.
The aws-actions/configure-aws-credentials@v4 action automatically handles the entire process from requesting the OIDC token to receiving the temporary credentials. From the workflow author's perspective, you only need to specify role-to-assume and the region. Trust Policy evaluation is handled internally by STS and does not appear as a separate API call.
Step-by-Step Setup Code
Register the GitHub OIDC Provider in AWS
You can do this directly in the AWS console, but managing it as IaC with Terraform is much better. You only need to register it once per account.
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
# AWS uses its own trusted CA for token.actions.githubusercontent.com
# and does not perform thumbprint validation.
# (See AWS official docs: Creating OpenID Connect (OIDC) identity providers)
# The value below is required for formatting purposes but is not used in actual validation,
# so there is no maintenance burden.
thumbprint_list = ["ffffffffffffffffffffffffffffffffffffffff"]
}Since the second half of 2023, AWS uses its own trusted CA store for the GitHub OIDC provider, so you don't need to worry about thumbprint values. It's best not to blindly copy examples that rely on a specific thumbprint value.
Create the IAM Role and Trust Policy
The reason sub claims are specified as an array is explained in the section immediately below.
resource "aws_iam_role" "github_actions_ecr_deploy" {
name = "github-actions-ecr-deploy"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRoleWithWebIdentity"
Principal = {
Federated = aws_iam_openid_connect_provider.github.arn
}
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
"token.actions.githubusercontent.com:sub" = [
"repo:my-org/my-repo:ref:refs/heads/main",
"repo:my-org@123456/my-repo@789012:ref:refs/heads/main"
]
}
}
}]
})
}If you want to allow only exact matches for sub, it's safer to pass an array with StringEquals rather than StringLike. The principle is to switch to StringLike only when you actually need to use wildcards (*).
GitHub Actions Workflow
name: Deploy to ECR
on:
push:
branches: [main]
permissions:
id-token: write # Required for requesting the OIDC token
contents: read
env:
AWS_REGION: ap-northeast-2
ECR_REPOSITORY: my-repo
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::ACCOUNT_ID:role/github-actions-ecr-deploy
aws-region: ${{ env.AWS_REGION }}
- id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build and push Docker image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAGYou must attach id: login-ecr to the amazon-ecr-login@v2 step in order to reference steps.login-ecr.outputs.registry below it. ECR_REPOSITORY also needs to be defined in the workflow-level env to prevent empty-string substitution accidents.
Trust Policy sub Claim Design — This Is the Critical Part
The July 2026 sub Claim Format Change
According to the GitHub Changelog announcement from April 23, 2026, starting July 15, 2026, newly created repositories will use an immutable sub claim format that includes numeric IDs.
| Type | sub Claim Format |
|---|---|
| Legacy (repos created before 2026-07-15) | repo:my-org/my-repo:ref:refs/heads/main |
| New (repos created after 2026-07-15, or opt-in) | repo:my-org@{ORG_ID}/my-repo@{REPO_ID}:ref:refs/heads/main |
ORG_ID and REPO_ID can be retrieved via the GitHub REST API. The organization ID is the id field in the GET /orgs/{org} response, and the repository ID is the id field in the GET /repos/{owner}/{repo} response. You can also query them immediately with the GitHub CLI, e.g. gh api /orgs/my-org --jq .id.
If you leave an existing Trust Policy as-is, the token from a newly created repository will not match the condition, resulting in a Not authorized to perform sts:AssumeRoleWithWebIdentity error. The currently recommended approach is to update the policy to allow both formats using an array.
{
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": [
"repo:my-org/my-repo:ref:refs/heads/main",
"repo:my-org@123456/my-repo@789012:ref:refs/heads/main"
]
}
}Which sub Condition Should You Use?
The appropriate sub condition string varies depending on the nature of your deployment. The table below summarizes examples by scenario.
| Deployment Type | Recommended sub Condition (legacy format) |
Notes |
|---|---|---|
| Dev branch deployment | repo:my-org/my-repo:ref:refs/heads/dev |
Minimum scope at the branch level |
| Tag-based release | repo:my-org/my-repo:ref:refs/tags/v* |
Specify tag namespace; use StringLike with wildcards |
| PR validation (read-only) | repo:my-org/my-repo:pull_request |
Do not grant write permissions |
| Production deployment | repo:my-org/my-repo:environment:production |
Recommended with GitHub Environment + required reviewers |
For production deployments, a branch filter alone is insufficient. Using GitHub Environments narrows the sub claim to the format repo:my-org/my-repo:environment:production, and combining it with required reviewers lets it serve as a real deployment gate.
Conditions You Must Never Use
Using wildcards too broadly, as shown below, puts the role in a state where any workflow within the organization can assume it.
{
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:*"
}
}The Datadog Security Labs research found many instances of exactly this configuration in the wild. When the condition in a trust policy is too broad, arbitrary code running from a fork or PR can potentially assume the role.
Least-Privilege Design by Use Case
Having a single IAM role cover all repositories and all environments violates the principle of least privilege. Roles should be separated by environment (dev/staging/prod) and, where possible, by repository.
ECR Image Push Role
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["ecr:GetAuthorizationToken"],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload"
],
"Resource": "arn:aws:ecr:ap-northeast-2:ACCOUNT_ID:repository/my-repo"
}
]
}ecr:GetAuthorizationToken cannot specify a resource ARN and requires "*", but all remaining actions can be scoped to a specific ECR repository ARN.
ECS Service Update Role
The minimum required permissions to change the ECR image tag and redeploy the ECS service.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecs:RegisterTaskDefinition",
"ecs:DescribeTaskDefinition"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"ecs:UpdateService",
"ecs:DescribeServices"
],
"Resource": "arn:aws:ecs:ap-northeast-2:ACCOUNT_ID:service/my-cluster/my-service"
},
{
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::ACCOUNT_ID:role/ecs-task-execution-role"
}
]
}RegisterTaskDefinition and DescribeTaskDefinition do not support resource-level permissions, making "*" unavoidable, but UpdateService and DescribeServices must be scoped to a specific service ARN to comply with the principle.
S3 Static Deployment + CloudFront Cache Invalidation
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:DeleteObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::my-static-bucket",
"arn:aws:s3:::my-static-bucket/*"
]
},
{
"Effect": "Allow",
"Action": "cloudfront:CreateInvalidation",
"Resource": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DISTRIBUTION_ID"
}
]
}Role Design Principles at a Glance
| Principle | Bad Example | Good Example |
|---|---|---|
| Environment separation | Single role covering dev and prod | Separate roles per environment |
| Repository scope | repo:my-org/* wildcard |
repo:my-org/specific-repo:... exact match |
| Resource scope | "Resource": "*" overuse |
Specify exact ARNs |
| Privilege escalation prevention | iam:* allowed |
Apply Permission Boundary |
| Auditing | No dedicated auditing | IAM Access Analyzer + CloudTrail |
Privilege-escalation actions like iam:* should not be granted to deployment roles. To prevent a role from expanding its own permissions, you can additionally configure a Permission Boundary. Using AWS IAM Access Analyzer, you can receive suggestions for least-privilege policies based on CloudTrail logs of permissions that were actually used.
Tradeoffs and Common Mistakes
OIDC vs. Long-Lived Credentials Comparison
| Item | OIDC | Long-Lived Credentials |
|---|---|---|
| Credential exposure risk | Low (temporary credentials, default 1 hour) | High (long-lived keys with no expiration) |
| Setup complexity | Medium (requires Trust Policy design) | Low (ready to use immediately after key issuance) |
| Credential rotation | Automatic | Manual (periodic key rotation required) |
| Audit trail | Excellent (repo and ref recorded in CloudTrail) | Fair (difficult to identify which pipeline was used) |
| Multi-cloud | Requires audience separation design | Independent per cloud |
Common Mistakes in Practice
Missing id-token: write permission. Without this single line at the top of the workflow, the configure-aws-credentials step will fail with an error like Error: Credentials could not be loaded, please check your action inputs: Could not load credentials from any providers. The hint that the root cause is a missing permission isn't very obvious, so it's a good habit to always check the permissions block first.
Relying on branch filters alone without using Environments. If you only set a ref:refs/heads/main condition for production, anyone with push access to main can trigger a deployment. Setting up reviewers in a GitHub Environment adds an additional security gate.
Placing the configure-aws-credentials step too late. The OIDC token is issued during workflow execution and has a limited validity window. Place the authentication step early in the workflow, and for long-running jobs, consider adding a re-authentication step to account for session expiration.
Sudden deployment failures in new repositories. If a repository was created after July 15, 2026, the sub claim format will have changed and will no longer match the existing Trust Policy. Updating the Trust Policy to an array is the currently recommended fix.
Recent Discussion on Audience Configuration
In a security research post from August 2026, it was pointed out that the common practice of hard-coding the aud claim of GitHub OIDC tokens to a fixed value like sts.amazonaws.com is itself a risk factor. In practice, tokens can be requested with an arbitrary audience via the audience input of the configure-aws-credentials action or the argument to getIDToken() in actions/core. If you operate in a multi-cloud environment, it is safer to use a distinct audience string per cloud and explicitly specify the correct audience with StringEquals in your Trust Policy conditions as well.
The Role Vending Machine Pattern for Large Teams
As teams grow, manually creating IAM roles one by one becomes burdensome. aws-samples/role-vending-machine is an official AWS sample implementing a pattern where each team submits a PR to request an IAM role per GitHub repository, the security team reviews and approves it, and provisioning happens automatically.
This setup may be overkill for small teams, but for organizations running dozens or more pipelines, it's highly valuable for maintaining consistent permissions and simplifying security audits.
Closing Thoughts
With GitHub Actions OIDC, how accurately you understand and apply Trust Policy design and the principle of least privilege determines your actual security posture far more than the setup itself. Eliminating credentials is just the beginning — what truly matters is how narrowly you define the sub claim condition, whether you explicitly validate the audience, and how tightly you scope the resource range in your IAM policies.
Just like the July 2026 sub claim format change, OIDC-based pipelines are subject to policy changes from both GitHub and AWS. Managing Trust Policies as IaC with tools like Terraform allows you to respond to such changes much more quickly. If you're currently managing AWS credentials via GitHub Secrets, migrating to OIDC and then using IAM Access Analyzer to analyze your actual usage patterns and refine your policies toward least privilege is a great place to start.
References
- Configuring OpenID Connect in Amazon Web Services — GitHub Official Documentation
- Use IAM roles to connect GitHub Actions to AWS — AWS Security Blog
- aws-actions/configure-aws-credentials — Official GitHub Action Repository
- Immutable subject claims for GitHub Actions OIDC tokens — GitHub Changelog (2026.04.23)
- tj-actions/changed-files compromise analysis — StepSecurity (2025.03)
- Provision least-privilege IAM roles by deploying a role vending machine solution — AWS Prescriptive Guidance
- aws-samples/role-vending-machine — Official AWS Sample
- Exploring GitHub-to-AWS keyless authentication flaws — Datadog Security Labs
- GitHub Actions needs OIDC audience constraints — Security Research (2026.08)
- OpenID Connect reference — GitHub Docs
- Build and push Docker images to Amazon ECR using GitHub Actions and Terraform — AWS Prescriptive Guidance