All articles

// Knowledge.log — 技術記事

GitHub Actions with AWS OIDC: No More Long-Lived Keys

Replace static GitHub Actions keys with OIDC, a restricted IAM role, a permissions boundary, and verifiable failure tests.

Storing AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in GitHub secrets gets the deployment working, but it creates a permanent credential outside AWS. Then come rotation, forgotten consumers, and the healthy question of which job is still using the old key.

OIDC replaces that arrangement with a temporary credential: the job receives a token from GitHub, presents it to AWS STS, and assumes an IAM role. There is no long-lived access key in the repository, and AWS accepts the token only when aud and sub match what the trust policy authorized.

The result we will build is a pipeline with:

  • GitHub's OIDC provider registered in the AWS account;
  • an IAM role restricted to the repository and the production environment;
  • a permissions boundary limiting that role's ceiling;
  • aws-actions/configure-aws-credentials@v6.2.3 in the workflow;
  • tests that distinguish authentication failures from authorization failures.

Why a long-lived key is an operational risk

An IAM user access key is a long-lived credential made up of an ID and a secret. The secret appears only once when the key is created. If it is lost, there is no button to reveal it again: you must delete the key and create another one.

Each IAM user can have at most two access keys. That is why rotation usually follows this sequence:

  1. create the second key;
  2. update every consumer;
  3. verify that no one is using the first key;
  4. delete the first key.

If a workflow, script, or secret in another repository gets left behind in step 3, removal becomes an outage. If the key leaks, it remains valid until it is revoked.

The IAM security best practices documentation itself recommends temporary credentials and roles for workloads. With OIDC, the permanent credential disappears from GitHub: each job gets a temporary session for that deployment.

Prerequisites and names used

You need enough administrative permission to create an OIDC provider, policies, and an IAM role. In the examples, replace these values with your own:

  • AWS account: 111122223333;
  • organization: octo-org;
  • repository: octo-repo;
  • bucket: meu-site-prod;
  • CloudFront distribution: E123EXAMPLE;
  • region: us-east-1.

The role will be named gha-deploy-prod. Avoid the name GitHubActions, as noted by the action's own project. A specific name also helps when the ARN appears in STS.

The combination used here is the issuer https://token.actions.githubusercontent.com, audience sts.amazonaws.com, and 2. This is the current version in the July 22, 2026 release notes. The GitHub page still shows a 2024 SHA in one example, from the v4 era; that is not the basis for this setup.

1. Register GitHub's OIDC IdP

The OIDC provider is created once per AWS account for this issuer:

aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com

The resulting ARN will have this form:

arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com

Do not add a thumbprint to the command. For this registration, AWS uses its trusted CA library; the action also states that the fingerprint is unnecessary and is ignored when provided.

The equivalent configuration and expected fields are in the AWS documentation for creating an OIDC identity provider.

2. Restrict the trust policy with aud and sub

Create trust-policy.json:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::111122223333: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:octo-org/octo-repo:environment:prod"
        }
      }
    }
  ]
}

The aud must be sts.amazonaws.com, which is the audience requested by the action by default. The sub limits which GitHub context can assume the role. AWS requires this claim to be present in the GitHub provider's trust policy and does not accept a restriction made up only of wildcards.

The shape of sub changes with the job context:

Job contextsub value
environment: prodrepo:ORG/REPO:environment:prod
branch without an environmentrepo:ORG/REPO:ref:refs/heads/main
tag without an environmentrepo:ORG/REPO:ref:refs/tags/VERSAO
pull request without an environmentrepo:ORG/REPO:pull_request

There is an important trap: when the job defines environment: prod, the environment:prod segment replaces the branch in sub. It is not appended to ref:refs/heads/main. Therefore, the trust policy above must be combined with GitHub Environment protection rules that allow deployment to prod only from approved branches or tags.

If you do not use GitHub Environment, replace the condition with:

"token.actions.githubusercontent.com:sub": "repo:octo-org/octo-repo:ref:refs/heads/main"

The GitHub OIDC claims reference documents how the subject is assembled. AWS, in turn, details the condition keys available for OIDC federation.

Repositories with an immutable sub

For repositories created after July 15, 2026, GitHub.com may issue the subject with immutable owner and repository IDs. The format is:

repo:OWNER@OWNER-ID/REPO@REPO-ID:ref:refs/heads/BRANCH

Renames, transfers, and opt-in for older repositories can also lead to this format. Do not copy example IDs into production. First inspect the claims issued for your repository, then record the real IDs in the trust policy. If the token uses the immutable subject while the policy still expects names only, AssumeRoleWithWebIdentity will be denied.

3. Put a ceiling on the role with a permissions boundary

The trust policy answers who can assume the role. The identity policy answers what the session can request. A permissions boundary does not grant access by itself: it limits the maximum the identity policy can grant, and an action is authorized only when both allow it.

Create deploy-boundary.json:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::meu-site-prod/*"
    },
    {
      "Effect": "Allow",
      "Action": "cloudfront:CreateInvalidation",
      "Resource": "arn:aws:cloudfront::111122223333:distribution/E123EXAMPLE"
    }
  ]
}

Create the managed policy that will serve as the boundary:

aws iam create-policy \
  --policy-name gha-deploy-prod-boundary \
  --policy-document file://deploy-boundary.json

Now create deploy-policy.json, the role's identity policy:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:DeleteBucket"
      ],
      "Resource": [
        "arn:aws:s3:::meu-site-prod",
        "arn:aws:s3:::meu-site-prod/*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": "cloudfront:CreateInvalidation",
      "Resource": "arn:aws:cloudfront::111122223333:distribution/E123EXAMPLE"
    }
  ]
}

s3:DeleteBucket is there deliberately for the negative test. The identity policy tries to grant it; the boundary does not allow it. After verification, remove that action from the identity policy—the test has made its point.

Create the role with the boundary and attach the inline policy:

aws iam create-role \
  --role-name gha-deploy-prod \
  --assume-role-policy-document file://trust-policy.json \
  --permissions-boundary \
    arn:aws:iam::111122223333:policy/gha-deploy-prod-boundary

aws iam put-role-policy \
  --role-name gha-deploy-prod \
  --policy-name gha-deploy-prod-policy \
  --policy-document file://deploy-policy.json

In the example, the boundary is the direct ceiling for the deployment role in an isolated account, as described by AWS in permissions boundaries for IAM identities.

If the company uses AWS Organizations, an SCP can serve as the organizational ceiling for member accounts. It does not grant access either, and it is not required for this flow. Use the boundary to protect the role in this account; use an SCP when the rule must apply across multiple accounts in the organization.

4. Replace static secrets with the role in the workflow

Remove the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY secrets used by CI from the repository. The workflow must not pass them to the action.

Create or update .github/workflows/deploy.yml:

name: deploy

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: prod

    steps:
      - uses: actions/checkout@v7

      - name: Configurar credenciais temporárias da AWS
        uses: aws-actions/configure-aws-credentials@v6.2.3
        with:
          role-to-assume: arn:aws:iam::111122223333:role/gha-deploy-prod
          aws-region: us-east-1
          role-session-name: gha-octo-repo-prod

      - name: Confirmar a identidade assumida
        run: aws sts get-caller-identity

      - name: Publicar um artefato
        run: aws s3 cp dist/index.html s3://meu-site-prod/index.html

      - name: Invalidar o HTML no CloudFront
        run: >-
          aws cloudfront create-invalidation
          --distribution-id E123EXAMPLE
          --paths /index.html

permissions.id-token: write allows the job to request the OIDC JWT. It does not grant write access to the repository. contents: read is still required for checkout.

The action calls AssumeRoleWithWebIdentity in STS and exports temporary AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_SESSION_TOKEN values to the job. They belong to the assumed session, not to the old permanent secrets. The GitHub documentation shows the flow in configuring OIDC with AWS, while the STS API is described in 4.

5. Verify identity, trust, and the authorization boundary

The first test is:

aws sts get-caller-identity

Inside the workflow, the expected output contains the correct account and a session ARN similar to:

arn:aws:sts::111122223333:assumed-role/gha-deploy-prod/gha-octo-repo-prod

This proves which temporary identity is active. It does not prove that the identity policy is correct. GetCallerIdentity requires no IAM permission and continues to identify the session even when other actions are blocked.

Run two negative tests as well, because a policy that never fails has not really been introduced to the environment yet.

Intentional trust failure

Temporarily change one side of the relationship. For example, remove environment: prod from the job while the trust policy requires:

repo:octo-org/octo-repo:environment:prod

Without an environment, the branch token will contain:

repo:octo-org/octo-repo:ref:refs/heads/main

The action should fail before obtaining credentials, with an authorization error for sts:AssumeRoleWithWebIdentity. The same kind of failure occurs if permissions.id-token: write is missing or if aud does not match.

Restore the environment before continuing.

Intentional authorization failure

With the role assumed and get-caller-identity working, run:

aws s3api delete-bucket --bucket meu-site-prod

The example's identity policy contains s3:DeleteBucket, but the permissions boundary does not. The expected result is AccessDenied. That distinction matters:

  • failure in AssumeRoleWithWebIdentity: a token, audience, subject, or trust problem;
  • role assumed, API denied: a problem—or an intentional safeguard—in the effective authorization.

After the test, remove s3:DeleteBucket from deploy-policy.json and apply it again:

aws iam put-role-policy \
  --role-name gha-deploy-prod \
  --policy-name gha-deploy-prod-policy \
  --policy-document file://deploy-policy.json

A trap worth reviewing before the merge

  • Do not use repo:ORG/REPO:* unless you consciously accept that branches, tags, pull requests, and environments from that repository fall within its scope.

When DevDojo would adopt OIDC

For CI on GitHub.com that deploys repeatedly to an AWS account, the recommendation is firm: use OIDC, a trust policy restricted by aud and sub, a protected environment, and a permissions boundary on the role. The setup cost is a one-time expense; removing the permanent credential eliminates an ongoing routine of exposure and rotation.

The exception would be human break-glass access, kept outside CI, with an explicit custody and revocation process. Even then, the access key belongs to the emergency human user, not to GitHub Actions. If automated deployment still depends on it, the migration is not finished.

As a next step, run the workflow first with only get-caller-identity, perform both negative tests, and only then enable the real deployment actions. That way, you verify the trust policy and the authorization boundary before enabling the production path.

awsgithub-actionsiam

// Continue.training — 次のステップ

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles