Document

SUBSCRIBE TO GET FULL ACCESS TO THE E-BOOKS FOR FREE 🎁SUBSCRIBE NOW

Professional Dropdown with Icon

SUBSCRIBE NOW TO GET FREE ACCESS TO EBOOKS

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

  1. 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 a sub (subject) string that combines them.
  2. The action aws-actions/configure-aws-credentials calls sts:AssumeRoleWithWebIdentity with that token and the ARN of your role.
  3. AWS checks the token signature against the provider you registered, then evaluates the role’s trust policy: audience must be sts.amazonaws.com, and the sub must match your condition.
  4. 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: production

Step 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-providers

Since 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.json

Never 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: true

The 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-staging trusted for environment:staging, gha-my-repo-prod for environment:production with required reviewers on the GitHub environment.
  • Read-only role for pull requests: trusted for repo:my-org/my-repo:pull_request, allowed only to run terraform plan (read APIs + state bucket read).
  • Customise the sub claim to include the workflow file or the job’s runner_environment using the GitHub REST API (PUT /repos/{owner}/{repo}/actions/oidc/customization/sub), then match it in the trust policy.
  • Shorter sessions: role-duration-seconds: 900 in 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

  1. Create the provider and role as above, with the same permissions the IAM user had.
  2. Switch the workflow to configure-aws-credentials with role-to-assume; delete the aws-access-key-id/aws-secret-access-key inputs.
  3. Run the pipeline; check CloudTrail for the new AssumeRoleWithWebIdentity event.
  4. Deactivate the old access key (aws iam update-access-key --status Inactive), wait a week, then delete the key and the user.
  5. Remove the secrets from GitHub.

Troubleshooting

ErrorCause and fix
Credentials could not be loaded, please check your action inputs: Could not load credentials from any providersMissing permissions: id-token: write at workflow or job level.
Not authorized to perform sts:AssumeRoleWithWebIdentityThe 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 audienceTrust policy requires aud = sts.amazonaws.com; older actions used a different audience. Use configure-aws-credentials@v4.
No OpenIDConnect provider found in your accountProvider not created in this account/partition, or the ARN in the trust policy has a typo.
AccessDenied on the actual AWS callTrust works; the permissions policy is too narrow. Check CloudTrail for the denied action and resource.
Works on push, fails on workflow_dispatchManual 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 aud and sub.
  • Workflows need id-token: write and aws-actions/configure-aws-credentials with role-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.

Share your love

Leave a Reply

Your email address will not be published. Required fields are marked *