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.
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.
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.
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.
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 context | sub value GitHub sends |
|---|---|
| push or workflow_dispatch on a branch, no environment | repo:acme/shop:ref:refs/heads/main |
| tag build, no environment | repo:acme/shop:ref:refs/tags/v1.4.0 |
job declares environment: production | repo:acme/shop:environment:production |
| pull_request event, no environment | repo: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.
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.
{"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.
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.
permissions:id-token: write # required to request the OIDC tokencontents: readjobs:deploy:runs-on: ubuntu-latestenvironment: production # the trust policy matches this environmentsteps:- uses: actions/checkout@v7- uses: aws-actions/configure-aws-credentials@v6with:role-to-assume: arn:aws:iam::123456789012:role/gha-deploy-prodaws-region: us-east-1role-duration-seconds: 900 # 15 min: enough for this deploy- run: aws s3 sync ./dist s3://my-artifacts-prod/
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 elapsesScope 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.
aws iam list-access-keys --user-name ci-deployerAKIA…QX7 Active 2024-11-03aws iam get-access-key-last-used --access-key-id AKIA…QX7LastUsedDate: 2026-09-11 ServiceName: s3 Region: us-east-1used yesterday: find the caller (CloudTrail, userIdentity.accessKeyId) before deletingaws iam update-access-key --user-name ci-deployer --access-key-id AKIA…QX7 --status Inactiveaws iam delete-access-key --user-name ci-deployer --access-key-id AKIA…QX7The 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