For years the standard way to deploy from CI to AWS was to create an IAM user, generate an access key, and paste it into the CI system’s secret store. Those keys never expire, leak in logs and forks, and are the number one finding in AWS security audits. OpenID Connect (OIDC) removes them entirely: GitHub signs a short-lived token for each workflow run, AWS verifies it against GitHub’s public keys and issues temporary credentials for an IAM role whose trust policy says exactly which repository, branch or environment is allowed. No secret is stored anywhere. This tutorial sets it up end to end, with Terraform and the CLI, then deploys a static site to S3 and a container to ECS.
Prerequisites: an AWS account where you can create IAM roles, a GitHub repository (Actions enabled), the AWS CLI, and optionally Terraform 1.10+ (see the Terraform series). Replace <YOUR_ACCOUNT_ID>, my-org and my-repo with your values.
How the handshake works
- The workflow requests a JWT from GitHub’s OIDC provider (
token.actions.githubusercontent.com). The token’s claims describe the run: repository, branch (ref), environment, actor, workflow file, and asub(subject) string that combines them. - The action
aws-actions/configure-aws-credentialscallssts:AssumeRoleWithWebIdentitywith that token and the ARN of your role. - AWS checks the token signature against the provider you registered, then evaluates the role’s trust policy: audience must be
sts.amazonaws.com, and thesubmust match your condition. - STS returns credentials valid for one hour (configurable); the rest of the job uses them like any AWS CLI session.
The sub claim is the key to least privilege. Its default format is:
repo:my-org/my-repo:ref:refs/heads/main # push to main
repo:my-org/my-repo:ref:refs/tags/v1.2.3 # tag
repo:my-org/my-repo:pull_request # PR from the same repo
repo:my-org/my-repo:environment:production # job with environment: productionStep 1 – Register GitHub as an identity provider (once per account)
aws iam create-open-id-connect-provider
--url https://token.actions.githubusercontent.com
--client-id-list sts.amazonaws.com
--thumbprint-list ffffffffffffffffffffffffffffffffffffffff
aws iam list-open-id-connect-providersSince mid-2023 AWS validates GitHub’s certificates against its own trusted CA list, so the thumbprint is no longer checked; a placeholder of 40 f characters is the documented, accepted value. Older tutorials that compute the thumbprint with openssl still work but are unnecessary.
Step 2 – Create the deployment role
The trust policy answers “who may assume this role”; the permissions policy answers “what may they do”. Keep both narrow: one role per repository and environment, permissions limited to the resources the workflow deploys.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "GitHubActionsOIDC",
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::<YOUR_ACCOUNT_ID>: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:my-org/my-repo:ref:refs/heads/main",
"repo:my-org/my-repo:environment:production"
]
}
}
}
]
}aws iam create-role --role-name gha-my-repo-deploy
--assume-role-policy-document file://trust.json
--max-session-duration 3600
# Permissions: example for a static site on S3 + CloudFront
cat > permissions.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": "arn:aws:s3:::my-site-bucket" },
{ "Effect": "Allow", "Action": ["s3:PutObject","s3:DeleteObject","s3:GetObject"], "Resource": "arn:aws:s3:::my-site-bucket/*" },
{ "Effect": "Allow", "Action": ["cloudfront:CreateInvalidation"], "Resource": "arn:aws:cloudfront::<YOUR_ACCOUNT_ID>:distribution/E1234567890ABC" }
]
}
EOF
aws iam put-role-policy --role-name gha-my-repo-deploy --policy-name deploy --policy-document file://permissions.jsonNever write "sub": "repo:my-org/*" in a production trust policy, and never omit the sub condition: without it, any GitHub repository in the world could assume your role. Also avoid matching on pull_request for roles that can change infrastructure; PR workflows run untrusted code from forks.
Same thing in Terraform
variable "github_repo" {
type = string
default = "my-org/my-repo"
}
data "aws_caller_identity" "current" {}
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["ffffffffffffffffffffffffffffffffffffffff"]
}
data "aws_iam_policy_document" "trust" {
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = [
"repo:${var.github_repo}:ref:refs/heads/main",
"repo:${var.github_repo}:environment:production",
]
}
}
}
resource "aws_iam_role" "gha_deploy" {
name = "gha-my-repo-deploy"
assume_role_policy = data.aws_iam_policy_document.trust.json
max_session_duration = 3600
}
resource "aws_iam_role_policy_attachment" "deploy" {
role = aws_iam_role.gha_deploy.name
policy_arn = aws_iam_policy.deploy.arn # define your least-privilege policy separately
}
output "role_arn" {
value = aws_iam_role.gha_deploy.arn
}Step 3 – The workflow
Two things are mandatory: the id-token: write permission (so the job can request the JWT) and the configure-aws-credentials step with role-to-assume. Store the role ARN as a repository variable (not a secret; it is not sensitive).
# .github/workflows/deploy-site.yml
name: Deploy static site
on:
push:
branches: [main]
permissions:
id-token: write # request the OIDC token
contents: read # checkout
concurrency:
group: deploy-prod
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # adds "environment:production" to the sub claim; enables reviewers
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }} # arn:aws:iam::<YOUR_ACCOUNT_ID>:role/gha-my-repo-deploy
aws-region: ca-central-1
role-session-name: gha-${{ github.run_id }}
- name: Who am I?
run: aws sts get-caller-identity
- name: Build
run: |
npm ci
npm run build
- name: Sync to S3
run: aws s3 sync ./dist s3://my-site-bucket --delete --cache-control "public,max-age=300"
- name: Invalidate CloudFront
run: aws cloudfront create-invalidation --distribution-id E1234567890ABC --paths "/*"Push to main. The “Who am I?” step prints an ARN like arn:aws:sts::<YOUR_ACCOUNT_ID>:assumed-role/gha-my-repo-deploy/gha-1234567; that session name shows up in CloudTrail, which makes every deployment traceable to a workflow run.
Step 4 – Deploy a container to ECS (ECR push + service update)
# .github/workflows/deploy-ecs.yml (excerpt)
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
env:
AWS_REGION: ca-central-1
ECR_REPO: my-api
ECS_CLUSTER: prod
ECS_SERVICE: my-api
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Login to ECR
id: ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build and push
id: build
env:
REGISTRY: ${{ steps.ecr.outputs.registry }}
run: |
IMAGE="$REGISTRY/$ECR_REPO:${GITHUB_SHA::12}"
docker build -t "$IMAGE" .
docker push "$IMAGE"
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
- name: Render task definition
id: task
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: infra/taskdef.json
container-name: api
image: ${{ steps.build.outputs.image }}
- name: Deploy
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: ${{ steps.task.outputs.task-definition }}
cluster: ${{ env.ECS_CLUSTER }}
service: ${{ env.ECS_SERVICE }}
wait-for-service-stability: trueThe role for this workflow needs ecr:GetAuthorizationToken (on *), push permissions on the one repository, ecs:RegisterTaskDefinition, ecs:UpdateService/DescribeServices on the service, and iam:PassRole restricted to the task execution and task roles.
Hardening options
- Separate roles per environment:
gha-my-repo-stagingtrusted forenvironment:staging,gha-my-repo-prodforenvironment:productionwith required reviewers on the GitHub environment. - Read-only role for pull requests: trusted for
repo:my-org/my-repo:pull_request, allowed only to runterraform plan(read APIs + state bucket read). - Customise the
subclaim to include the workflow file or the job’srunner_environmentusing the GitHub REST API (PUT /repos/{owner}/{repo}/actions/oidc/customization/sub), then match it in the trust policy. - Shorter sessions:
role-duration-seconds: 900in the action for jobs that finish quickly. - Permission boundary on all
gha-*roles so a mis-scoped policy can never grant IAM or Organizations rights. - Detect the old way: an IAM Access Analyzer or a Config rule flagging IAM users with active access keys, and CloudTrail alerts on
CreateAccessKey.
Migrating from access keys
- Create the provider and role as above, with the same permissions the IAM user had.
- Switch the workflow to
configure-aws-credentialswithrole-to-assume; delete theaws-access-key-id/aws-secret-access-keyinputs. - Run the pipeline; check CloudTrail for the new
AssumeRoleWithWebIdentityevent. - Deactivate the old access key (
aws iam update-access-key --status Inactive), wait a week, then delete the key and the user. - Remove the secrets from GitHub.
Troubleshooting
| Error | Cause and fix |
|---|---|
Credentials could not be loaded, please check your action inputs: Could not load credentials from any providers | Missing permissions: id-token: write at workflow or job level. |
Not authorized to perform sts:AssumeRoleWithWebIdentity | The sub in the token does not match the trust policy. Print it by decoding the token: add a step curl -sH "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value | cut -d. -f2 | base64 -d. Typical mismatch: job has environment: so the sub is environment:production, not ref:refs/heads/main. |
Incorrect token audience | Trust policy requires aud = sts.amazonaws.com; older actions used a different audience. Use configure-aws-credentials@v4. |
No OpenIDConnect provider found in your account | Provider not created in this account/partition, or the ARN in the trust policy has a typo. |
AccessDenied on the actual AWS call | Trust works; the permissions policy is too narrow. Check CloudTrail for the denied action and resource. |
Works on push, fails on workflow_dispatch | Manual runs from another branch produce a different ref; add it or use environments in the condition. |
The same pattern on other CI systems
OIDC federation is not GitHub-specific. GitLab CI issues an id_tokens JWT from gitlab.com (or your instance) with a project_path claim; Azure Pipelines service connections support workload identity federation to AWS through a custom OIDC provider; Bitbucket Pipelines, CircleCI and Buildkite all publish an issuer URL. In every case the recipe is identical: register the issuer as an IAM identity provider, create a role whose trust policy pins aud and the provider’s project or repository claim, and exchange the token with AssumeRoleWithWebIdentity. Long-lived access keys in CI should be considered a legacy pattern everywhere.
Key takeaways
- OIDC replaces stored access keys with per-run, one-hour credentials and a CloudTrail-visible session name.
- One provider per account, one role per repository and environment, trust policy pinned on
audandsub. - Workflows need
id-token: writeandaws-actions/configure-aws-credentialswithrole-to-assume. - Use GitHub environments for approvals and to scope production roles; give pull requests read-only roles.
- Delete the old IAM users once the new path is proven.
Related: AWS IAM, Amazon ECS, Terraform IAM. Official docs: Configuring OpenID Connect in AWS (GitHub), Creating OIDC identity providers (AWS).
Retour parcours Git — hub de la série et leçons sœurs.


