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.
aud is not a control. Every GitHub Actions job on github.com can ask for a token with aud set to sts.amazonaws.com, including a workflow in a repository somebody created five minutes ago. The one other thing they need is your role ARN, which leaks into build logs, screenshots, tickets and shared modules, and which was never a secret. StringLike with repo:acme/* is the same mistake in a smaller size: it trusts every repository in your organization, the abandoned ones and the ones a contractor can push to included. Pin the whole subject string. If you have to wildcard part of it, wildcard the branch, never the repository.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.
id-token: write from fork runs. It stops being survivable the day somebody widens the plan role for convenience. Put the workflow files under CODEOWNERS review, the GitHub file that says which people must approve changes to which paths, so editing the pipeline needs the same sign-off as editing infrastructure.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.
repo:acme/infra:environment:production, and that GitHub environment requires a reviewer. Someone deletes the environment: production line from the apply job so it stops waiting for approval, and the edit lands on main. What happens the next time the apply job runs?aws iam list-access-keys --user-name ci-terraform and get back one key beginning AKIAV3XK7QZ2NLEXAMPLE, Status Active. Later, in a build log, aws sts get-caller-identity shows a session whose key begins ASIA. What do those two prefixes tell you?sts:AssumeRoleWithWebIdentity from your GitHub OIDC (OpenID Connect) provider and checks that token.actions.githubusercontent.com:aud equals sts.amazonaws.com, with no sub (subject) condition at all. The role ARN (Amazon Resource Name) shows up in a build log. How bad is that?Try this
Run aws iam list-access-keys --user-name ci-terraform on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: leave out the sub condition and you have published your account. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.