---
title: 'GitHub Actions OIDC to AWS: Keyless Terraform (2026)'
description: Deploy Terraform to AWS from GitHub Actions with no long-lived secrets — configure-aws-credentials v6, IAM role trust policy, sub-claim hardening (2026).
url: https://nerdleveltech.com/github-actions-oidc-aws-terraform-keyless-deploys-tutorial
language: en
date: '2026-05-27'
category: cloud-devops
tags:
  - github actions
  - oidc
  - aws
  - terraform
  - iam
  - security
  - ci-cd
  - keyless deployment
markdown_url: https://nerdleveltech.com/github-actions-oidc-aws-terraform-keyless-deploys-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/github-actions-oidc-aws-terraform-keyless-deploys-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# GitHub Actions OIDC to AWS: Keyless Terraform (2026)

## TL;DR

GitHub Actions can mint a short-lived JSON Web Token on every workflow run and exchange it directly with AWS STS for temporary credentials — no `AWS_ACCESS_KEY_ID` in repository secrets, ever. This tutorial wires up an end-to-end keyless Terraform deploy: an IAM OpenID Connect provider, a scoped role trust policy, the `aws-actions/configure-aws-credentials@v6` step, and a hardening pass that pins deploys to a single branch and environment. About 25 minutes.

**Answer snippet:** To deploy Terraform to AWS from GitHub Actions without long-lived secrets, register an IAM OIDC provider for `token.actions.githubusercontent.com`, create a role whose trust policy pins the `sub` claim to your repo and branch, grant the workflow `id-token: write`, and use `aws-actions/configure-aws-credentials@v6` to exchange the GitHub JWT for short-lived STS credentials.

## What you'll build

- An IAM OpenID Connect identity provider that trusts GitHub's token issuer
- A least-privilege IAM role that can only be assumed by your repository on the `main` branch or the `production` environment
- A GitHub Actions workflow that runs `terraform plan` and `terraform apply` against AWS with zero long-lived secrets
- A hardened trust policy that survives the immutable subject claim rollout landing for new repositories on July 15, 2026[^immutable]

## Prerequisites

- An AWS account with `iam:CreateOpenIDConnectProvider`, `iam:CreateRole`, and `iam:AttachRolePolicy` available (an admin or a sufficiently scoped bootstrap role)
- A GitHub repository (any plan — OIDC works on Free, Pro, Team, and Enterprise)
- Terraform 1.15.2 or later (S3-native state locking via `use_lockfile = true` requires 1.11.0+)[^tf]
- AWS CLI v2.34+
- An S3 bucket for Terraform remote state (the [Terraform S3 native state locking tutorial](https://nerdleveltech.com/terraform-s3-native-state-locking-tutorial) shows how to provision one with locking enabled and no DynamoDB)

## Step 1: Create the AWS IAM OpenID Connect provider

This is a one-time, per-account bootstrap. The OIDC provider tells AWS to trust JWTs signed by `token.actions.githubusercontent.com`. You can create it once with the CLI and then manage everything else in Terraform.

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

That's the entire command. Three things worth noting:

The `--thumbprint-list` flag is optional. The AWS CLI reference explicitly states that when omitted, IAM retrieves the top intermediate CA thumbprint itself.[^awscli] More importantly, since July 2023 AWS verifies the JWKS endpoint TLS certificate against its library of trusted root CAs and **ignores any thumbprint you pass for the GitHub provider**.[^thumbprint] Tutorials that still pin `6938fd4d98bab03faadb97b34396831e3780aea1` are propagating a pre-2023 ritual that does nothing today.

`sts.amazonaws.com` is the audience (`aud` claim) the GitHub-issued JWT will carry by default. If you're targeting an AWS China partition you'd use `sts.amazonaws.com.cn` here and pass a matching `audience` input to the action.[^configure]

You can only register the same OIDC URL once per AWS account, so if `EntityAlreadyExists` comes back, the provider is already there — move on to Step 2.

If you'd rather declare the provider in Terraform alongside the role, the resource is two lines:

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

`thumbprint_list` became optional back in provider v5.81.0 and stays optional in the current v6.x major (GA since April 2026). Omit it entirely — when the attribute isn't passed, IAM auto-retrieves the top intermediate CA thumbprint at create time, and AWS then ignores it at runtime for the GitHub provider. Note an open behavior bug (#40509): once you set thumbprints in state, removing them from config does not clear them from IAM, so don't write `thumbprint_list = []` either (the AWS API may reject the explicit empty list with `Thumbprint list must contain at least one entry`).[^tfthumbprint] If you inherited a stack with thumbprints already in state, leave them alone — they don't hurt anything.

## Step 2: Define the IAM role trust policy

This is the security boundary. The trust policy says **who** can assume the role; without it (or with a sloppy condition), any repository on GitHub can mint a JWT and assume your role. We'll pin it to one repository, one branch.

Create `iam.tf`:

```hcl
data "aws_caller_identity" "current" {}

locals {
  github_org  = "your-org"      # CHANGE ME
  github_repo = "your-repo"     # CHANGE ME
  branch      = "main"
  account_id  = data.aws_caller_identity.current.account_id
}

data "aws_iam_policy_document" "github_trust" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRoleWithWebIdentity"]

    principals {
      type        = "Federated"
      identifiers = [
        "arn:aws:iam::${local.account_id}:oidc-provider/token.actions.githubusercontent.com"
      ]
    }

    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:aud"
      values   = ["sts.amazonaws.com"]
    }

    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:sub"
      values   = [
        "repo:${local.github_org}/${local.github_repo}:ref:refs/heads/${local.branch}"
      ]
    }
  }
}

resource "aws_iam_role" "gha_terraform" {
  name               = "gha-terraform-deploy"
  assume_role_policy = data.aws_iam_policy_document.github_trust.json
  description        = "Assumed by GitHub Actions via OIDC for Terraform deploys"
  max_session_duration = 3600
}
```

Three details that catch teams later:

`StringEquals` on the `sub` claim does an exact match. If you want any branch (or any tag, or any PR) to be able to deploy, switch to `StringLike` and use a wildcard pattern like `repo:org/repo:ref:refs/heads/*`. For production deploys you almost always want `StringEquals` to a single branch.[^subclaim]

Naming the role literally "GitHubActions" has a long-standing report of failing in unexpected ways (issue #953 on the action repo).[^naming] Use a descriptive name like `gha-terraform-deploy`.

`max_session_duration` defaults to 3600 seconds (one hour). If your Terraform plan + apply genuinely takes longer than that, raise it up to 43200 (twelve hours), but you'll also need to pass `role-duration-seconds` to the action to request the longer session.[^configure]

## Step 3: Attach least-privilege permissions for Terraform state and resources

The role needs two permission sets: state-backend access, and whatever your Terraform actually manages. For just the backend, against an S3 state bucket called `acme-tf-state` with native S3 locking enabled:

```hcl
data "aws_iam_policy_document" "tf_state" {
  statement {
    sid     = "StateBucketList"
    effect  = "Allow"
    actions = ["s3:ListBucket"]
    resources = ["arn:aws:s3:::acme-tf-state"]
  }

  statement {
    sid     = "StateObjectRW"
    effect  = "Allow"
    actions = ["s3:GetObject", "s3:PutObject"]
    resources = ["arn:aws:s3:::acme-tf-state/prod/terraform.tfstate"]
  }

  statement {
    sid     = "StateLockfileRW"
    effect  = "Allow"
    actions = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
    resources = ["arn:aws:s3:::acme-tf-state/prod/terraform.tfstate.tflock"]
  }
}

resource "aws_iam_policy" "tf_state" {
  name   = "gha-terraform-deploy-state"
  policy = data.aws_iam_policy_document.tf_state.json
}

resource "aws_iam_role_policy_attachment" "tf_state" {
  role       = aws_iam_role.gha_terraform.name
  policy_arn = aws_iam_policy.tf_state.arn
}
```

The state file itself does not need `s3:DeleteObject` — Terraform overwrites it in place. The lock file does need `DeleteObject` because Terraform deletes the `.tflock` object to release the lock at the end of an apply.[^s3lock] Then attach whatever managed or inline policies your stack actually needs to create resources (`AmazonEC2FullAccess`, a custom policy for your specific services, etc.) on top.

## Step 4: Wire up the workflow with configure-aws-credentials v6 and id-token write

Now the workflow. The current floating tag `aws-actions/configure-aws-credentials@v6` resolves to v6.1.1 as of May 5, 2026; pin to the immutable `@v6.1.1` if you want full supply-chain locking.[^v6release] Create `.github/workflows/terraform.yml`:

```yaml
name: terraform-deploy

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: ./infra
    steps:
      - uses: actions/checkout@v6.0.2

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v6.1.1
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gha-terraform-deploy
          role-session-name: gha-${{ github.run_id }}
          aws-region: us-east-1

      - name: Verify identity
        run: aws sts get-caller-identity

      - uses: hashicorp/setup-terraform@v4.0.1
        with:
          terraform_version: 1.15.2

      - run: terraform init
      - run: terraform plan -out=tfplan
      - run: terraform apply -auto-approve tfplan
```

Two permissions are doing specific work: `id-token: write` lets the runner request an OIDC token from GitHub's identity provider (required for OIDC, no exceptions), and `contents: read` lets `actions/checkout` fetch the repository via the GitHub-provided token. The moment you declare a `permissions:` block, all unlisted permissions default to none, so omitting `contents: read` is a common cause of mysterious checkout failures on private and internal repos.[^perms] If you later add a job-level `permissions:` block, remember that job-level permissions fully replace workflow-level — they don't merge.

`role-session-name: gha-${{ github.run_id }}` makes every assumed session traceable back to the exact workflow run in CloudTrail. The action's default is just `"GitHubActions"`, which is correct but uninformative when you're chasing an audit log entry.[^configure]

## Step 5: Sub claim hardening — pin to an environment, not a branch

A branch-scoped trust policy is good. An environment-scoped one is better — and it lets you require manual approval before the role can even be assumed. Two coordinated changes are needed.

First, in the GitHub repository settings, create a `production` environment with required reviewers, then add the `environment:` key to the deploy job in `terraform.yml`:

```yaml
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    # ...rest unchanged
```

Second, update the trust policy condition to match `environment:production` instead of `ref:refs/heads/main`. When a job references an environment, GitHub overrides the subject claim to `repo:org/repo:environment:<name>` regardless of whether the trigger was a push or a `workflow_dispatch`, so you swap the matching value the trust policy expects:

```hcl
condition {
  test     = "StringEquals"
  variable = "token.actions.githubusercontent.com:sub"
  values   = [
    "repo:${local.github_org}/${local.github_repo}:environment:production"
  ]
}
```

The combined effect: the job pauses with status "Waiting" until a required reviewer approves it, and the role can only be assumed by workflows running inside that approved environment — no human, no token, no AWS access — even if someone pushes directly to `main`.[^envscope]

If you need both push-to-main and tag-release deploys to share a role, switch to `StringLike` and list both patterns:

```hcl
condition {
  test     = "StringLike"
  variable = "token.actions.githubusercontent.com:sub"
  values   = [
    "repo:${local.github_org}/${local.github_repo}:ref:refs/heads/main",
    "repo:${local.github_org}/${local.github_repo}:ref:refs/tags/v*"
  ]
}
```

What you should never do is leave the trust policy with only an `aud` condition, or scope the `sub` to `repo:${org}/*:*`. Either one means any branch in any of your repositories — including a feature branch a contractor pushed yesterday — can assume the role.

## Step 6: Prepare for immutable subject claims (rolling out July 15, 2026)

This is the 2026-specific gotcha most existing tutorials don't cover. On April 23, 2026 GitHub announced that OIDC subject claims will embed immutable owner and repository IDs to defend against name-reuse attacks. Repositories created **after July 15, 2026** will automatically issue tokens with subjects like:[^immutable]

```text
repo:octo-org@123456/octo-repo@456789:ref:refs/heads/main
```

The immutable format inserts the numeric owner and repository IDs after an `@` delimiter appended directly to the name (`OWNER@OWNER-ID/REPO@REPO-ID`) — GitHub chose `@` specifically because it cannot appear in GitHub usernames or repository names. Existing repositories keep emitting the mutable name-only format unless the owner opts in early (opt-in is available today via the repository or organization OIDC settings). Two takeaways:

If your trust policy uses `StringEquals` on the full subject string, your trust policy for a new repository created after July 15 must include the owner ID and repository ID. You can discover them with `gh api repos/<owner>/<repo> --jq '{id, owner_id: .owner.id}'` (the GitHub CLI rejects a leading slash on the endpoint path). For existing repos that haven't opted in, the old format still works.

If you can't predict the IDs ahead of time, a forward-compatible pattern is `StringLike` with two glob terms — one matching the legacy format, one matching the immutable format:

```hcl
values = [
  "repo:${local.github_org}/${local.github_repo}:ref:refs/heads/main",
  "repo:${local.github_org}@*/${local.github_repo}@*:ref:refs/heads/main"
]
```

If you adopted the environment-scoped policy from Step 5, swap `:ref:refs/heads/main` for `:environment:production` in both glob terms. This is the cleanest migration path until you opt the repository in and capture the IDs.

## Verification

Push a no-op commit to `main` and watch the workflow run. The "Verify identity" step should print something like:

```json
{
    "UserId": "AROAEXAMPLE:gha-1234567890",
    "Account": "123456789012",
    "Arn": "arn:aws:sts::123456789012:assumed-role/gha-terraform-deploy/gha-1234567890"
}
```

In CloudTrail, look up the matching `AssumeRoleWithWebIdentity` event. The `requestParameters.roleSessionName` will be `gha-${run_id}` and `responseElements.subjectFromWebIdentityToken` will be the exact value the GitHub JWT carried in its `sub` claim — `repo:<org>/<repo>:ref:refs/heads/main` if you stopped at Step 4, or `repo:<org>/<repo>:environment:production` if you hardened to the environment in Step 5. That field is your audit anchor.

## Troubleshooting

**`Error: Could not load credentials from any providers`** — almost always missing `permissions: id-token: write` at either the workflow or job level. Adding it at the job level wins.[^perms]

**`Not authorized to perform sts:AssumeRoleWithWebIdentity`** — the trust policy `sub` condition doesn't match what the workflow actually sends. PR runs send `repo:org/repo:pull_request`, not `ref:refs/heads/...`. Tag pushes send `ref:refs/tags/<tag>`. Add `actions-oidc-debugger` to print the actual claim, then update the policy.[^configure]

**`InvalidIdentityToken: Provider not found`** — the IAM OIDC provider doesn't exist in the account, or you registered it with a different URL. The URL must be exactly `https://token.actions.githubusercontent.com` with no trailing slash and no path.

**`InvalidIdentityToken: Couldn't retrieve verification key`** — usually a custom `audience:` was set on the action but not added to the IAM provider's `client_id_list`. The `aud` claim in the JWT must match a client ID registered on the provider.

**Plan succeeds, apply hangs at the lock acquire** — you migrated from DynamoDB locking and forgot to drop the old `dynamodb_table` arg from your backend config, or the role lacks `s3:DeleteObject` on the `.tflock` key. See the [Terraform S3 state locking tutorial](https://nerdleveltech.com/terraform-s3-native-state-locking-tutorial) for the dual-locking migration path.

## Next steps

Apply the same trust policy pattern to additional roles for staging vs. production, scoped to their respective environments. Add `inline-session-policy` on the action to further tighten what the assumed role can do at runtime — useful when one role serves several workflows. And revoke any long-lived `AWS_ACCESS_KEY_ID` you still have lying around in repository secrets: they're now actively dangerous, because the only thing preventing exfiltration is a layer you no longer need.

[^immutable]: GitHub Changelog, "Immutable subject claims for GitHub Actions OIDC tokens," April 23, 2026 — [github.blog](https://github.blog/changelog/2026-04-23-immutable-subject-claims-for-github-actions-oidc-tokens/)
[^tf]: HashiCorp Terraform releases — [releases.hashicorp.com/terraform/](https://releases.hashicorp.com/terraform/)
[^awscli]: AWS CLI Command Reference, `iam create-open-id-connect-provider` — [docs.aws.amazon.com](https://docs.aws.amazon.com/cli/latest/reference/iam/create-open-id-connect-provider.html)
[^thumbprint]: GitHub Changelog, "OIDC integration with AWS no longer requires pinning of intermediate TLS certificates," July 13, 2023 — [github.blog](https://github.blog/changelog/2023-07-13-github-actions-oidc-integration-with-aws-no-longer-requires-pinning-of-intermediate-tls-certificates/)
[^configure]: `aws-actions/configure-aws-credentials` README (v6.1.1, May 5, 2026) — [github.com](https://github.com/aws-actions/configure-aws-credentials)
[^tfthumbprint]: hashicorp/terraform-provider-aws issue #40509: "`aws_iam_openid_connect_provider` cannot clear `thumbprint_list` once set" — [github.com](https://github.com/hashicorp/terraform-provider-aws/issues/40509)
[^subclaim]: GitHub Docs, "Configuring OpenID Connect in Amazon Web Services" — [docs.github.com](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services)
[^naming]: aws-actions/configure-aws-credentials issue #953, "If the assumed role name is `GitHubActions` the action will fail with a non specific error"; the README links it under "Naming your role 'GitHubActions' has been reported to not work" — [github.com](https://github.com/aws-actions/configure-aws-credentials/issues/953)
[^s3lock]: HashiCorp, "Backend Type: s3" (use_lockfile reference) — [developer.hashicorp.com](https://developer.hashicorp.com/terraform/language/backend/s3)
[^v6release]: aws-actions/configure-aws-credentials release v6.1.1, published 2026-05-05 — [github.com](https://github.com/aws-actions/configure-aws-credentials/releases/tag/v6.1.1)
[^perms]: GitHub Docs, "OpenID Connect" — [docs.github.com](https://docs.github.com/en/actions/concepts/security/openid-connect)
[^envscope]: AWS Security Blog, "Use IAM roles to connect GitHub Actions to actions in AWS" — [aws.amazon.com](https://aws.amazon.com/blogs/security/use-iam-roles-to-connect-github-actions-to-actions-in-aws/)
