How to deploy from GitHub Actions to AWS without access keys (OIDC)

· 8 min read · aws · ci/cd

Long-lived AWS keys in GitHub secrets are the most common credential leak we find in audits. OIDC replaces them with short-lived credentials that only one repository, branch or environment can get. Here is the full setup, including the trust-policy details most tutorials get wrong.

The short version: create an IAM OIDC identity provider for token.actions.githubusercontent.com, create an IAM role whose trust policy only accepts tokens from your repository and branch (or environment), give the workflow permissions: id-token: write, and call aws-actions/configure-aws-credentials with role-to-assume. No secrets are stored in GitHub, and every credential expires on its own.

The rest of this guide walks through each step with Terraform, the console path, a least-privilege policy for a typical container deploy, the errors you will hit, and how to scale it across several AWS accounts.

Why long-lived access keys are a risk

An IAM user access key is valid until someone rotates or deletes it, and in practice nobody does. It is copied into GitHub secrets, a laptop, a CI variable on another platform, and sometimes a Slack message. When one of those leaks, the attacker has the same access as your pipeline, often AdministratorAccess, with no expiry.

OIDC changes the model. On every job run, GitHub issues a signed JSON Web Token that describes exactly who is asking: the repository, the branch or tag, the environment, the event. AWS STS verifies the token and returns temporary credentials for one role. The default session lasts one hour. There is nothing to rotate and nothing useful to steal from your repository settings.

It also gives you something keys never did: the ability to say "only the main branch of this repository may deploy to production" and have AWS enforce it.

Step 1: create the OIDC identity provider

You need one IAM OIDC provider per AWS account, with the URL https://token.actions.githubusercontent.com and the audience sts.amazonaws.com. Create it once and reuse it for every role in that account.

With Terraform

resource "aws_iam_openid_connect_provider" "github" {
  url            = "https://token.actions.githubusercontent.com"
  client_id_list = ["sts.amazonaws.com"]
}

You will still see tutorials that hard-code a thumbprint_list. It is no longer needed. AWS now verifies GitHub's JWKS endpoint against its own library of trusted root certificate authorities and only falls back to thumbprints for providers that don't use one (see the IAM documentation on OIDC providers), and the Terraform AWS provider marks the argument as optional. The configure-aws-credentials README says the same: any thumbprint you set is ignored.

In the console

  1. Open IAM, choose Identity providers, then Add provider.
  2. Choose OpenID Connect.
  3. Provider URL: https://token.actions.githubusercontent.com. Audience: sts.amazonaws.com.
  4. Choose Add provider.

If the provider already exists (someone experimented a year ago), import it into Terraform rather than creating a second one. IAM only allows one provider per URL in an account.

Step 2: write a trust policy that is actually scoped

The trust policy must check two claims: token.actions.githubusercontent.com:aud equals sts.amazonaws.com, and token.actions.githubusercontent.com:sub matches the exact repository and branch or environment you want. The sub condition is where the security lives.

Here is a role that only the main branch of acme/api can assume:

data "aws_iam_policy_document" "github_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     = "StringEquals"
      variable = "token.actions.githubusercontent.com:sub"
      values   = ["repo:acme/api:ref:refs/heads/main"]
    }
  }
}

resource "aws_iam_role" "github_deploy" {
  name               = "github-deploy-api"
  assume_role_policy = data.aws_iam_policy_document.github_trust.json
}

Which sub value do you get?

GitHub builds the sub claim differently depending on the job, and this is the source of most "not authorized" errors. From the GitHub OIDC reference:

Job contextsub claim
Job uses environment: productionrepo:acme/api:environment:production
Push to a branch, no environmentrepo:acme/api:ref:refs/heads/main
Tag push, no environmentrepo:acme/api:ref:refs/tags/v1.4.0
Pull request event, no environmentrepo:acme/api:pull_request

The environment wins. As soon as a job declares environment:, the branch disappears from the sub claim, so a trust policy written for ref:refs/heads/main stops matching.

For production we prefer environment scoping: trust repo:acme/api:environment:production, then configure the GitHub environment with deployment branch rules (only main) and, if you want, required reviewers. AWS's own guidance strongly recommends protection rules on any environment used this way, because the environment name alone does not say which branch ran.

Immutable repository IDs

GitHub now issues a sub that includes owner and repository IDs, in the form repo:acme@ORG-ID/api@REPO-ID:ref:refs/heads/main, for repositories created after 15 July 2026 and for older ones that opt in. If you create a new repository and copy an old trust policy, expect it to fail. Check which format your repository emits before writing the condition.

What not to do

A StringLike condition of repo:acme/* lets every repository in the organisation, including a pull request from any branch, assume the role. IAM already rejects trust policies for the GitHub provider where sub is missing or just a wildcard, but it will happily accept an organisation-wide wildcard. Treat anything broader than one repository as a finding.

Step 3: give the role least-privilege permissions

The deploy role should be able to do the deploy and nothing else. For a typical "build an image, push to ECR, roll an ECS service" pipeline, that is a short policy:

data "aws_iam_policy_document" "deploy" {
  statement {
    actions   = ["ecr:GetAuthorizationToken"]
    resources = ["*"]
  }

  statement {
    actions = [
      "ecr:BatchCheckLayerAvailability",
      "ecr:InitiateLayerUpload",
      "ecr:UploadLayerPart",
      "ecr:CompleteLayerUpload",
      "ecr:PutImage",
      "ecr:BatchGetImage",
    ]
    resources = [aws_ecr_repository.api.arn]
  }

  statement {
    actions   = ["ecs:RegisterTaskDefinition", "ecs:DescribeTaskDefinition"]
    resources = ["*"]
  }

  statement {
    actions   = ["ecs:UpdateService", "ecs:DescribeServices"]
    resources = [aws_ecs_service.api.id]
  }

  statement {
    actions = ["iam:PassRole"]
    resources = [
      aws_iam_role.task_execution.arn,
      aws_iam_role.task.arn,
    ]
    condition {
      test     = "StringEquals"
      variable = "iam:PassedToService"
      values   = ["ecs-tasks.amazonaws.com"]
    }
  }
}

resource "aws_iam_role_policy" "deploy" {
  role   = aws_iam_role.github_deploy.id
  policy = data.aws_iam_policy_document.deploy.json
}

Two habits keep this manageable. Use separate roles for separate jobs: a read-only terraform plan role that pull requests can assume, and a write role that only the protected environment can assume. And keep iam:PassRole pinned to specific roles; an unrestricted PassRole is a privilege-escalation path.

Step 4: the workflow

The job needs id-token: write to request the token, and the action needs the role ARN and a region. The current major version of the action is v6, per the official repository.

name: deploy

on:
  push:
    branches: [main]

permissions:
  id-token: write   # lets the job request the OIDC token
  contents: read    # needed by actions/checkout

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v7

      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: arn:aws:iam::222222222222:role/github-deploy-api
          aws-region: eu-central-1
          role-session-name: gh-${{ github.run_id }}

      - run: aws sts get-caller-identity

      # build, push to ECR, update the ECS service...

Notes on the inputs:

Setting permissions at the workflow level also drops every other permission of the GITHUB_TOKEN to none, which is what you want. We cover more pipeline hygiene in the CI/CD anti-patterns we keep fixing.

Common errors and how to fix them

Almost every failure is either a missing permission on the workflow or a sub claim that doesn't match the trust policy.

"Credentials could not be loaded" / "Could not load credentials from any providers"

The job could not get an OIDC token, so the action had nothing to exchange. Add id-token: write to the workflow or job permissions. If you set permissions at job level, the workflow-level block no longer applies to that job. Pull requests from forks run with read-only permissions and can't request an OIDC token, so don't plan deploys around them.

"Not authorized to perform sts:AssumeRoleWithWebIdentity"

The token was valid but the trust policy rejected it. Check, in order:

  1. Does the job use environment:? Then sub is ...:environment:NAME, not ...:ref:refs/heads/main.
  2. Is the trigger pull_request? Then sub ends in :pull_request.
  3. Is it a new repository emitting the immutable org@ID/repo@ID format?
  4. Are organisation and repository names spelled with the right case?
  5. Did you use StringEquals with a value that contains *? Wildcards need StringLike.

To see the claims your job actually sends, decode the token in a throwaway job or use AWS CloudTrail: failed AssumeRoleWithWebIdentity calls are logged in the target account.

"No OpenIDConnect provider found in your account"

The role is in an account that has no GitHub provider. The provider must be in the same account as the role that trusts it.

"The requested DurationSeconds exceeds the MaxSessionDuration"

You asked for a longer session than the role allows. Lower role-duration-seconds or raise max_session_duration on the role.

The multi-account pattern

If you run separate AWS accounts for dev, staging and production (and you should, as we argue in setting up AWS Organizations from day one), put an OIDC provider and a deploy role in each workload account. Each role trusts only the matching GitHub environment.

AccountRoleTrusted sub
devgithub-deploy-apirepo:acme/api:environment:dev
staginggithub-deploy-apirepo:acme/api:environment:staging
prodgithub-deploy-apirepo:acme/api:environment:production

Store each role ARN as a GitHub environment variable (not a secret; an ARN isn't sensitive) and reference it with role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}. One workflow, three environments, and a compromised dev pipeline can't touch production because the production role will never accept a dev token.

Wrap the provider and role in a small Terraform module and apply it in every account from your account-baseline stack. If you organise Terraform state per account, this slots in naturally.

The alternative is a hub role in a shared account that GitHub assumes, which then chains into roles in each workload account. It works (the action supports it with role-chaining: true, and the target roles must allow sts:TagSession or you set role-skip-session-tagging), but in our experience it adds a single point of failure and makes trust policies harder to read. Direct per-account roles are simpler to audit.

Migration checklist

  1. List every IAM user with active access keys (aws iam generate-credential-report) and find the ones used by CI.
  2. Create the provider and a scoped role per pipeline and account.
  3. Switch one workflow, confirm in CloudTrail that the role is used, then deactivate the old key.
  4. After a week with no use, delete the key and the IAM user, and remove the GitHub secrets.
  5. Add a guardrail so new access keys raise an alert, for example an AWS Config rule or a Security Hub control.

Moving CI from static keys to OIDC is one of the first things we do when we set up cloud foundations for a team, alongside SSO for humans (see our guide to setting up IAM Identity Center). If you want it done properly across all your accounts and pipelines, our cloud migration and foundations work covers exactly this.

Frequently asked questions

Do I still need a thumbprint for the GitHub OIDC provider in AWS?

No. AWS verifies GitHub's token signing endpoint against its library of trusted root certificate authorities, so the thumbprint is not used for GitHub. In Terraform, thumbprint_list is optional for aws_iam_openid_connect_provider, and the configure-aws-credentials documentation notes that any thumbprint you set is ignored.

Why does GitHub Actions get "Not authorized to perform sts:AssumeRoleWithWebIdentity"?

The trust policy's sub condition does not match the token. The most common cause is a job that uses environment:, which changes the sub claim to repo:ORG/REPO:environment:NAME. Pull request triggers produce :pull_request, and new repositories may emit the immutable org@ID/repo@ID format.

Which version of aws-actions/configure-aws-credentials should I use?

As of October 2026 the current major version is v6. Reference it as @v6 or, better, pin it to a full commit SHA and let Dependabot update it. Use role-to-assume and aws-region, and make sure the workflow grants id-token: write.

Can one GitHub workflow deploy to several AWS accounts with OIDC?

Yes. Create an OIDC provider and a deploy role in each account, have each role trust a specific GitHub environment, and store the role ARN as an environment variable. The same workflow then picks the right role per environment, and production only accepts tokens from the production environment.