GitHub Actions to AWS with OIDC: no more long-lived keys

Federate your workflows into AWS with short-lived tokens scoped to repo and branch. Delete the access keys after.

Jan 20, 2026·Updated ·7 min readIntermediate·By SecOpsLog · documentation-verified

An AWS_ACCESS_KEY_ID in repository secrets has three properties that should worry you: it never expires, it works from any machine on the internet, and the first echo $AWS_SECRET_ACCESS_KEY in a debug step copies it into a log that outlives the job. OIDC federation replaces the key with a claim: GitHub signs a short-lived JWT that says which repository, ref or environment the job belongs to, and AWS STS trades that token for temporary credentials through AssumeRoleWithWebIdentity. Nothing long-lived is stored on the GitHub side, and the credentials expire on their own.

The whole security model then collapses into one JSON condition: the sub match in the IAM role trust policy. Get that condition wrong and any branch, fork or environment in the organisation can assume the deploy role. Get it slightly wrong, in the way this article's own original example did, and the job fails with an opaque Not authorized to perform sts:AssumeRoleWithWebIdentity.

What moves during the exchange

The JWT is the only credential that crosses from GitHub to AWS, and it carries the claims STS checks. Simplified: STS also validates the token signature against GitHub’s published keys through the identity provider you register in step one.

GitHub Actions to AWS OIDC exchange: the job requests a signed JWT, STS validates its claims against the role trust policy and returns temporary credentials Workflow jobid-token: writeGitHub OIDCsigns a JWTAWS STSAssumeRoleWithWebIdentityTemporary creds1 h by default123JWT claims (excerpt)isstoken.actions.githubusercontent.comsubrepo:acme/shop:environment:productionaudsts.amazonaws.comexpminutes after issueSTS accepts only when1signature (GitHub JWKS)2aud = provider client id3sub matches trust policy4role trusts the providerNothing long-lived is stored in GitHub: the JWT is minted per job and the credentials expire.

Register GitHub as an identity provider once

Create one IAM OIDC identity provider per account with URL https://token.actions.githubusercontent.com and client id (audience) sts.amazonaws.com. Every deploy role in the account trusts this same provider ARN and differs only in its sub condition. Older guides insist on a --thumbprint-list; AWS now verifies the provider's JWKS endpoint against its library of trusted root CAs and only falls back to the stored thumbprint when that certificate chain cannot be validated, so the flag is optional and IAM fills it in when you omit it.

create-oidc-provider.sh
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com
# --thumbprint-list is optional: IAM retrieves the thumbprint and AWS validates
# the JWKS endpoint against trusted root CAs, using the thumbprint only as a fallback.

The trust policy, and the subject the job actually sends

The trust policy allows sts:AssumeRoleWithWebIdentity from the federated provider under two conditions: aud must equal sts.amazonaws.com, and sub must match the subject GitHub puts in the token. The subject format depends on how the job runs, and that is where most broken setups come from:

sub claim by job context

Job contextsub value GitHub sends
push or workflow_dispatch on a branch, no environmentrepo:acme/shop:ref:refs/heads/main
tag build, no environmentrepo:acme/shop:ref:refs/tags/v1.4.0
job declares environment: productionrepo:acme/shop:environment:production
pull_request event, no environmentrepo:acme/shop:pull_request
repository created after 15 July 2026 (or opted in)repo:acme@123456/shop@456789:environment:production

The environment form replaces the ref form: a job that declares environment: production never presents refs/heads/main in its subject, even when it runs on main. A trust policy that pins the branch therefore denies the environment-scoped job, and vice versa. Decide which one you are protecting, then write the condition for exactly that. For production deploys the environment form is the better anchor because GitHub Environments add required reviewers and deployment branch rules on top of the trust policy.

Which subjects the production condition admits

One exact StringLike value admits one job context. The main-branch subject is denied not because main is untrusted, but because the job that targets the production environment never sends the ref form.

A GitHub OIDC trust policy that pins the production environment admits only the matching environment subject; branch, pull request, other-repository and other-environment subjects are denied Trust policy condition (keys are token.actions.githubusercontent.com:aud / :sub)StringEqualsaud = sts.amazonaws.comStringLikesub = repo:acme/shop:environment:productionsub claim presented by the jobresultrepo:acme/shop:environment:productionALLOWjob has environment: productionrepo:acme/shop:ref:refs/heads/mainDENYno environment, so the ref formrepo:acme/shop:pull_requestDENYpull_request, no environmentrepo:acme/other:environment:productionDENYother repositoryrepo:acme/shop:environment:stagingDENYother environmentRepositories created after 15 July 2026 present repo:acme@123456/shop@456789:… instead;the same conditions apply, written with the IDs.
trust-policy.json
{
"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:acme/shop:environment:production"
}
}
}

Repositories created after 15 July 2026 use the immutable subject format, which embeds the owner id and repository id (repo:acme@123456/shop@456789:…) so a recycled organisation or repository name can never reproduce an old subject. Older repositories keep the previous format unless you opt in through the OIDC settings. Check which format your repository emits before writing the condition; a mismatch fails in the same silent way as the environment trap. GitHub Enterprise Server does not have the immutable format.

Wildcards in sub are organisation-wide access
A condition of repo:acme/*:* admits every branch of every repository in the organisation, including a fork branch pushed by an outside contributor if any workflow with id-token: write runs on it. Pin the repository and either the environment or the exact ref. If several jobs legitimately need the same role, add several exact values to the StringLike array rather than widening one pattern.

The workflow side

Two things are required: the job needs permissions: id-token: write or GitHub will not mint a token, and the credentials action needs role-to-assume. aws-actions/configure-aws-credentials@v6 performs the STS exchange and exports the temporary credentials to later steps; do not pass aws-access-key-id or aws-secret-access-key alongside it. The default session is one hour, adjustable between 15 minutes and the role's maximum through role-duration-seconds. A deploy that takes six minutes has no reason to hold a one-hour session; shorten it.

.github/workflows/deploy.yml
permissions:
id-token: write # required to request the OIDC token
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # the trust policy matches this environment
steps:
- uses: actions/checkout@v7
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-deploy-prod
aws-region: us-east-1
role-duration-seconds: 900 # 15 min: enough for this deploy
- run: aws s3 sync ./dist s3://my-artifacts-prod/
bash — caller identity inside the job
aws sts get-caller-identity
{
"Account": "123456789012",
"Arn": "arn:aws:sts::123456789012:assumed-role/gha-deploy-prod/GitHubActions"
}
GitHubActions is the default role session name; the session ends when role-duration-seconds elapses

Scope the role's permission policy to the deploy itself: s3:PutObject on one bucket prefix, not AdministratorAccess. Federation only changes who can assume the role; the blast radius of a compromised job is still whatever the role's permissions allow. IAM Access Analyzer can generate that policy from what the role actually calls, which is the subject of least privilege with Access Analyzer.

Delete the keys the role replaced

A federated role sitting next to a still-valid static key is the old attack surface with an extra login path. Once the OIDC workflow has deployed successfully, list the old IAM user's keys, confirm with aws iam get-access-key-last-used that nothing else still uses them, and delete them. If something does still use a key, that consumer is the next migration, not a reason to keep the key.

bash — retire the static key
aws iam list-access-keys --user-name ci-deployer
AKIA…QX7 Active 2024-11-03
aws iam get-access-key-last-used --access-key-id AKIA…QX7
LastUsedDate: 2026-09-11 ServiceName: s3 Region: us-east-1
used yesterday: find the caller (CloudTrail, userIdentity.accessKeyId) before deleting
aws iam update-access-key --user-name ci-deployer --access-key-id AKIA…QX7 --status Inactive
aws iam delete-access-key --user-name ci-deployer --access-key-id AKIA…QX7

The same token works against GCP Workload Identity Federation and Azure federated credentials with the same subject rules, so the environment-vs-ref decision above is worth making once and writing down next to the workflow. Keyless signing with Cosign is the natural next step: the identity that just deployed the artifact can also sign it, with no signing key to store either.

Go deeper in a courseSoftware supply chain securityOIDC federation, keyless signing, SBOMs and provenance from commit to cluster.View course

Related posts

Quick reference