Provider credentials & least privilege
OIDC from CI instead of long-lived keys; split plan and apply roles.
Somewhere in most offices there is a spare front door key taped under the reception desk. It opens every room, it never expires, and it does not care who picks it up. Your version of that key is the long-lived cloud access key sitting in the secret store of your CI (continuous integration, the service that runs your builds for you) system, the one Terraform uses to build your infrastructure. The reception desk, in this picture, is a room where anyone who opens a pull request gets to run code of their own choosing.
Rank the targets in a normal engineering org and the runner that applies Terraform comes out near the top. It holds credentials strong enough to create infrastructure, which in practice means administrator or close to it. It works unattended, at all hours, from an IP (internet protocol) address nobody watches. It never sees an MFA (multi-factor authentication) prompt, because there is no human at the keyboard to answer one. And it runs code that arrives from pull requests. One added line in a build script and the key is on somebody else's laptop, still valid, for as long as nobody notices.
What the static key actually gives an attacker
A long-lived key is a bearer credential, which works the way a cinema ticket works: the person on the door checks the ticket, never the face. Whoever holds the string is the identity. No expiry, no device binding, no second factor, and no way to tell your build apart from a copy of your build. Access keys belonging to an IAM (Identity and Access Management, the AWS service that decides who may do what) user start with the letters `AKIA`. Temporary credentials, the kind you get by assuming a role, start with `ASIA` and stop working after an hour or so. Those four letters are a useful thing to search your pipeline logs, secret stores and wiki pages for.
$ aws iam list-access-keys --user-name ci-terraform
{"AccessKeyMetadata": [{"UserName": "ci-terraform","AccessKeyId": "AKIAV3XK7QZ2NLEXAMPLE","Status": "Active","CreateDate": "2023-02-14T09:11:03+00:00"}]}
Created in February 2023, still Active, and used by every build since. If it leaked in week three, every apply after that ran alongside somebody holding the same access you have. In CloudTrail, the AWS audit log that records every call made against the account, their calls and your calls carry the same identity, so the only thing separating them is the source address, and plenty of teams never alert on it. Rotating the key helps. Rotation is also a calendar chore that slips, and the better move is to have nothing left to rotate.
A badge that expires, instead of a key that never does
A visitor badge works differently from a spare key. You show up, the desk checks who you are, prints a badge that stops working at six, and prints a fresh one tomorrow if you come back. Nothing about yesterday's badge is worth stealing. OIDC (OpenID Connect, an identity layer built on top of the OAuth 2.0 authorization standard) is how your pipeline gets that same arrangement from a cloud provider. The wider name for the pattern is workload identity federation: two systems agree in advance on who may vouch for whom, and no secret is ever copied between them.
Here is the exchange, end to end. Your job asks GitHub for a JWT (JSON Web Token, a small signed document full of claims such as "this run belongs to repository acme/infra, on branch main"). GitHub signs it with a key whose public half is published at a fixed web address. Your job hands that token to AWS STS (Security Token Service) along with the ARN (Amazon Resource Name, the unique identifier AWS gives every resource) of the role it wants. STS downloads GitHub's public keys, checks the signature, tests the token's claims against that role's trust policy, and hands back an access key, a secret and a session token that all stop working in an hour. Nothing long-lived is stored on either side.
The trust policy is the whole control
Two claims do the work, and a posted letter is the closest everyday thing. The `aud` claim (audience) is the name on the envelope, the party the token was written for, which for AWS is `sts.amazonaws.com`. The `sub` claim (subject) is the line inside saying who the letter is about, in a format GitHub fixes for you: a push to a branch gives `repo:acme/infra:ref:refs/heads/main`, a pull request gives `repo:acme/infra:pull_request`, and a job that declares an environment gives `repo:acme/infra:environment:production`. Your trust policy is the single place where you decide which of those strings you are willing to accept.
Read the subject your own repository actually mints before you write that policy down. GitHub changed the format in July 2026: repositories created after the fifteenth of that month put permanent numeric identifiers in the string, so the push above arrives as `repo:acme@123456/infra@456789:ref:refs/heads/main`. Repositories older than that keep the name-based form until an administrator opts in, which is a setting rather than a surprise. To see yours, have a scratch job fetch a token, decode the payload and print the `sub` field alone, never the token itself. Pin the wrong form and every job fails closed, which costs you a morning and nothing else.
# One provider object per AWS account. There is no thumbprint_list here:# AWS now checks the issuer's TLS certificate against its own list of# trusted certificate authorities, so the thumbprint step that older guides# insist on is optional, and the argument is optional in the provider too.resource "aws_iam_openid_connect_provider" "github" {url = "https://token.actions.githubusercontent.com"client_id_list = ["sts.amazonaws.com"]}resource "aws_iam_role" "tf_plan" {name = "tf-plan-acme-infra"assume_role_policy = jsonencode({Version = "2012-10-17"Statement = [{Effect = "Allow"Action = "sts:AssumeRoleWithWebIdentity"Principal = { Federated = aws_iam_openid_connect_provider.github.arn }Condition = {StringEquals = {"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com""token.actions.githubusercontent.com:sub" = ["repo:acme/infra:pull_request","repo:acme/infra:ref:refs/heads/main",]}}}]})}
`StringEquals` with a list of values means match any one of these, so this role answers to pull request runs and to pushes on `main`, and to nothing else. A branch called `feature/x` produces `repo:acme/infra:ref:refs/heads/feature/x`, which is not in the list, so STS turns the job away before Terraform starts.
One role that reads, one role that writes
A plan is a read. Terraform refreshes what your resources look like now, compares that against your configuration, and prints the difference. Nothing changes. An apply is the opposite in every respect, and handing both jobs the same permissions throws that difference away. Split them: a plan role with read-only access to the cloud and read-only access to the state file, and a separate apply role, one per environment, holding the writes.
# The plan role reads the cloud and reads state. Nothing else.resource "aws_iam_role_policy_attachment" "plan_read" {role = aws_iam_role.tf_plan.namepolicy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"}# aws_kms_key.state is the key that encrypts the state bucket.resource "aws_iam_role_policy" "plan_state" {name = "read-state"role = aws_iam_role.tf_plan.namepolicy = jsonencode({Version = "2012-10-17"Statement = [{ Effect = "Allow", Action = ["s3:ListBucket"],Resource = "arn:aws:s3:::acme-tfstate-prod" },{ Effect = "Allow", Action = ["s3:GetObject"],Resource = "arn:aws:s3:::acme-tfstate-prod/network/terraform.tfstate" },{ Effect = "Allow", Action = ["kms:Decrypt"],Resource = aws_kms_key.state.arn },]})}# The apply role: same issuer, one subject, and it is an environment subject.resource "aws_iam_role" "tf_apply" {name = "tf-apply-acme-infra-prod"max_session_duration = 3600assume_role_policy = jsonencode({Version = "2012-10-17"Statement = [{Effect = "Allow"Action = "sts:AssumeRoleWithWebIdentity"Principal = { Federated = aws_iam_openid_connect_provider.github.arn }Condition = {StringEquals = {"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com""token.actions.githubusercontent.com:sub" = "repo:acme/infra:environment:production"}}}]})}
The apply role's subject line is the one to look at twice. GitHub writes `environment:production` into the token only when the job declares that environment, and a job that declares an environment with required reviewers does not start until a person clicks approve in the GitHub interface. So the approval is not paint on top of the pipeline. Skip it and the token carries a different subject, STS issues nothing, and the job has no credentials to apply with. The apply role holds the writes the plan role must not have: `s3:PutObject` and `s3:DeleteObject` on the state object in S3 (Simple Storage Service), plus `kms:GenerateDataKey` and `kms:Decrypt` on the KMS (Key Management Service) key that encrypts it.
Two details trip people up. `ReadOnlyAccess` is broader than its name suggests: it covers data reads such as `s3:GetObject` and `dynamodb:GetItem`, so a plan role wearing it can read your objects and your table rows, which may well be customer data. Narrow it to the services your configuration touches. Second, `terraform plan` takes a state lock by default, and taking a lock is a write. With the S3 backend's own locking (`use_lockfile = true`, Terraform 1.10 and later) the lock is an object named after your state key with `.tflock` on the end, written at the start and deleted at the end; the older DynamoDB table does the same job through `dynamodb:PutItem` and `dynamodb:DeleteItem` and is now deprecated. Run the plan job with `-lock=false` and keep locking where it earns its place, on apply. You give up little: a saved plan built against state that moved underneath it gets rejected at apply time anyway.
The pipeline step
The permission `id-token: write` is the line that lets a job request a token at all. Leave it out and the request fails outright, which is why you grant it on the jobs that need it rather than across every workflow you own.
name: terraformon:pull_request:push:branches: [main]permissions:contents: readjobs:plan:runs-on: ubuntu-latestpermissions:contents: readid-token: write # lets this job ask GitHub for an OIDC tokensteps:- uses: actions/checkout@v4- uses: aws-actions/configure-aws-credentials@v4with:role-to-assume: arn:aws:iam::111122223333:role/tf-plan-acme-infraaws-region: eu-west-1- run: aws sts get-caller-identity- uses: hashicorp/setup-terraform@v3- run: terraform init -input=false- run: terraform plan -input=false -lock=false -out=tfplan# a plan file holds resolved values, sensitive ones included:# keep retention short and the repository private- uses: actions/upload-artifact@v4with:name: tfplanpath: tfplanretention-days: 1apply:needs: planif: github.ref == 'refs/heads/main'runs-on: ubuntu-latestenvironment: production # required reviewers gate this jobpermissions:contents: readid-token: writesteps:- uses: actions/checkout@v4- uses: actions/download-artifact@v4with:name: tfplan- uses: aws-actions/configure-aws-credentials@v4with:role-to-assume: arn:aws:iam::111122223333:role/tf-apply-acme-infra-prodaws-region: eu-west-1- uses: hashicorp/setup-terraform@v3- run: terraform init -input=false- run: terraform apply -input=false tfplan
The action performs the exchange and writes `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and `AWS_SESSION_TOKEN` into the job's environment, where the Terraform AWS provider picks them up with no extra configuration. Notice what `terraform init` does a line later: it downloads provider binaries, which are programs that will then run holding those credentials. Commit `.terraform.lock.hcl`, the file that records the exact provider versions and a checksum for each package, so init verifies what it fetched instead of trusting whatever the registry serves that morning. One more thing about that plan artifact: marking a variable or an output `sensitive = true` hides the value from console output and encrypts nothing, not in the plan file and not in state, which is why the retention above is one day. Before trusting any of this, ask the cloud who you are.
$ aws sts get-caller-identity
{"UserId": "AROA4KZ7HXAMPLEQ3XYZ:GitHubActions","Account": "111122223333","Arn": "arn:aws:sts::111122223333:assumed-role/tf-plan-acme-infra/GitHubActions"}
`AROA4KZ7HXAMPLEQ3XYZ` is the role's own unique identifier and `GitHubActions` is the session name the action sets by default, so every line in the audit log carries both the role and the session that made the call. The access key behind that session starts with `ASIA` and dies within the hour. There is no `AKIA` anywhere in the pipeline, no cloud secret in the repository settings, nothing to rotate, and nothing worth stealing that still works tomorrow.
Azure and Google Cloud, same idea, different nouns
Azure calls it workload identity federation. You attach a federated credential to an app registration or a user-assigned managed identity, giving it the same issuer, the subject `repo:acme/infra:ref:refs/heads/main` and the audience `api://AzureADTokenExchange`, then set `use_oidc = true` in the `azurerm` provider block. Google Cloud calls it Workload Identity Federation. You create a workload identity pool, add an OIDC provider inside it, map `google.subject` to `assertion.sub`, and attach an attribute condition. Google makes that condition mandatory for GitHub, because one issuer serves every organization on the platform, and its own example stops at the owner: `assertion.repository_owner=='acme'`. Tighten it, the way you tighten `sub` on AWS: `assertion.repository=='acme/infra' && assertion.ref=='refs/heads/main'`.
Prove the control works, do not assume it
Run the negative test first, because a refusal is the only result that tells you anything. The call `sts:AssumeRoleWithWebIdentity` is unsigned, meaning AWS accepts it without a signature from any existing identity, so you can try it from your laptop with no AWS credentials configured at all, using a token minted by a workflow context you never authorized.
$ aws sts assume-role-with-web-identity \--role-arn arn:aws:iam::111122223333:role/tf-apply-acme-infra-prod \--role-session-name probe \--web-identity-token "$TOKEN_FROM_A_FEATURE_BRANCH_JOB"
An error occurred (AccessDenied) when calling the AssumeRoleWithWebIdentity operation: Not authorized to perform sts:AssumeRoleWithWebIdentity
That denial is the control working. Move quickly, because these tokens are good for minutes rather than hours. Then repeat the probe: against the apply role from a job with no environment declared, and against a token from a different repository if you can get hold of one. A guardrail nobody has tried to break is a guess.
The second check is the automated one, aimed at the trust policy that drifts open six months from now when somebody adds a role in a hurry. Checkov ships `CKV_AWS_358` for this shape, with two limits worth knowing: it reads `data "aws_iam_policy_document"` blocks, so an inline `jsonencode` trust policy like the ones above walks straight past it, and it treats an organization-wide `repo:acme/*` as a pass. Trivy, which absorbed tfsec's checks when that project was retired, carries no equivalent rule. For the exact shape your organization wants, write it once in Rego, the policy language of OPA (Open Policy Agent), and run it over the plan with conftest.
# conftest reads the "main" package unless you tell it otherwise.package mainimport rego.v1deny contains msg if {r := input.resource_changes[_]r.type == "aws_iam_role"doc := json.unmarshal(r.change.after.assume_role_policy)stmt := doc.Statement[_]stmt.Action == "sts:AssumeRoleWithWebIdentity" # Action can also be a listnot stmt.Condition.StringEquals["token.actions.githubusercontent.com:sub"]msg := sprintf("%s trusts GitHub OIDC without pinning sub", [r.address])}
$ terraform show -json tfplan > plan.json$ conftest test --policy policy.rego plan.json
FAIL - plan.json - main - aws_iam_role.ci_admin trusts GitHub OIDC without pinning sub1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions
conftest exits with status 1, the pipeline stops on that non-zero exit, and the role never reaches the cloud. Wire the rule into the same gate as the rest of your policy checks and nobody has to remember to read trust policies by eye.