GitHub Actions OIDC to AWS: Keyless Terraform (2026)

May 27, 2026

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 June 18, 20261

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+)2
  • AWS CLI v2.34+
  • An S3 bucket for Terraform remote state (the 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.

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.3 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.4 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.5

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:

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

In AWS provider 6.x thumbprint_list became optional. 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).6 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:

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

Naming the role literally "GitHubActions" has a long-standing report of failing in unexpected ways (issue #953 on the action repo).8 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.5

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:

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.9 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.10 Create .github/workflows/terraform.yml:

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.11 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.5

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:

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:

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.12

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

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 June 18, 2026)

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

repo:octocat-12345/my-repo-67890:ref:refs/heads/main

Existing repositories keep emitting the mutable name-only format unless the owner opts in early. Two takeaways:

If your trust policy uses StringEquals on the full subject string, your trust policy for a new repository created after June 18 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:

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:

{
    "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.11

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.5

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

Footnotes

  1. GitHub Changelog, "Immutable subject claims for GitHub Actions OIDC tokens," April 23, 2026 — github.blog 2

  2. HashiCorp Terraform releases — releases.hashicorp.com/terraform/

  3. AWS CLI Command Reference, iam create-open-id-connect-providerdocs.aws.amazon.com

  4. GitHub Changelog, "OIDC integration with AWS no longer requires pinning of intermediate TLS certificates," July 13, 2023 — github.blog

  5. aws-actions/configure-aws-credentials README (v6.1.1, May 5, 2026) — github.com 2 3 4

  6. hashicorp/terraform-provider-aws issue #40509: "aws_iam_openid_connect_provider cannot clear thumbprint_list once set" — github.com

  7. GitHub Docs, "Configuring OpenID Connect in Amazon Web Services" — docs.github.com

  8. 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

  9. HashiCorp, "Backend Type: s3" (use_lockfile reference) — developer.hashicorp.com

  10. aws-actions/configure-aws-credentials release v6.1.1, published 2026-05-05 — github.com

  11. GitHub Docs, "OpenID Connect" — docs.github.com 2

  12. AWS Security Blog, "Use IAM roles to connect GitHub Actions to actions in AWS" — aws.amazon.com


FREE WEEKLY NEWSLETTER

Stay on the Nerd Track

One email per week — courses, deep dives, tools, and AI experiments.

No spam. Unsubscribe anytime.