Keyless CI/CD on AWS: GitHub Actions + OIDC Instead of Long-Lived Access Keys
2026-11-04 · 8 min read · Nguyen Quoc Dat
For years, the default way to let a CI pipeline deploy to AWS was simple: create an IAM user, generate an access key, paste AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY into GitHub secrets, and move on. It works — until that key is leaked in a log, copied to a fork, or still valid two years after the engineer who created it left.
GitHub Actions supports OpenID Connect (OIDC), and AWS IAM can trust it directly. That means your workflow can get short-lived credentials for each job without storing any AWS secret in GitHub at all. Here's how I set it up and what to watch out for.
How it works
- When a job starts, GitHub can issue a signed JWT (the OIDC token) describing the run: repository, branch or tag, environment, workflow, and so on.
- The job calls AWS STS
AssumeRoleWithWebIdentitywith that token. - AWS validates the signature against the GitHub OIDC provider and checks the claims against the role's trust policy.
- If everything matches, STS returns temporary credentials (by default valid for one hour).
There is nothing long-lived to rotate, and nothing useful to steal from your repository settings.
Step 1: Register GitHub as an identity provider
Create an IAM OIDC identity provider once per AWS account:
- Provider URL:
https://token.actions.githubusercontent.com - Audience:
sts.amazonaws.com
With Terraform/OpenTofu, that's an aws_iam_openid_connect_provider resource. AWS now validates GitHub's certificate chain itself, so the thumbprint is no longer the headache it used to be.
Step 2: Create a role with a tight trust policy
This is the part that actually matters for security. The trust policy decides which workflows can assume the role:
{
"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:my-org/my-app:environment:production"
}
}
}
]
}
A few rules I follow:
- Always pin
sub. A trust policy that only checksaudlets any repository on GitHub assume your role. This is the most common — and most dangerous — mistake. - Prefer environments over branches for production roles (
repo:org/app:environment:production). Combined with GitHub environment protection rules (required reviewers, branch restrictions), you get an approval gate in front of your AWS credentials. - Use
StringLikewith wildcards carefully.repo:my-org/*is almost always too broad. - One role per purpose. A read-only role for
terraform planon pull requests, a separate deploy role forapplyon the protected environment.
Step 3: Use it in the workflow
name: deploy
on:
push:
branches: [main]
permissions:
id-token: write # required to request the OIDC token
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-deploy-production
aws-region: ap-southeast-1
- run: aws sts get-caller-identity
- run: ./scripts/deploy.sh
The key line is id-token: write. Without it, the job cannot request a token and configure-aws-credentials will fail with a fairly unhelpful error.
Common pitfalls
Not authorized to perform sts:AssumeRoleWithWebIdentity— almost always asubmismatch. Print the claim format you expect: a job withenvironment:gets...:environment:<name>, not...:ref:refs/heads/main.- Pull requests from forks don't get an OIDC token with write-level trust by default — which is exactly what you want. Don't work around it with
pull_request_targetunless you really understand the risk. - Reusable workflows change the claims you might want to check (
job_workflow_ref). If you centralize deploy logic, consider pinning that too. - Session duration: keep the default one hour unless a job genuinely needs more; raise the role's max session duration explicitly if so.
Migrating from access keys
- Create the OIDC provider and the new roles alongside the existing IAM user.
- Switch workflows one by one to
role-to-assume. - Watch CloudTrail: once the old access key shows no
LastUsedactivity, deactivate it, wait, then delete it. - Add a guardrail — for example, an SCP or AWS Config rule that flags new IAM user access keys — so long-lived keys don't creep back in.
Why it's worth it
Beyond fewer secrets, you get a much cleaner audit story. Every CloudTrail event carries the role session, and the trust policy documents exactly which repository and environment may deploy where. For teams working under HIPAA or ISO 27001, "no long-lived credentials in CI" is an easy control to explain and an easy one to prove.
If your pipelines still hold static AWS keys, this is one of the highest-value, lowest-effort security improvements you can make this sprint.