CoursesTerraform security and complianceProvider credentials & least privilege

Provider credentials & least privilege

OIDC from CI instead of long-lived keys; split plan and apply roles.

Intermediate25 min · lesson 3 of 12

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.

terminal
$ aws iam list-access-keys --user-name ci-terraform
output
{
"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.

One credential exchange, start to finish
1Job starts
workflow grants permissions: id-token: write
2GitHub mints a JWT
claims include aud, sub, repository, ref
3Job calls sts:AssumeRoleWithWebIdentity
sends the token and a role ARN, no secret
4STS checks the signature
against the issuer's published public keys
5STS checks the trust policy
aud and sub must match what you allowed
6Temporary credentials
ASIA key plus session token, one hour
Steps 4 and 5 are different checks. The signature proves GitHub minted the token. The conditions decide which GitHub workflow it came from, and only you can write that part.

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.

main.tf
# 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.

Leave out the sub condition and you have published your account
A trust policy that checks only 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.

main.tf
# 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.name
policy_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.name
policy = 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 = 3600
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: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.

.github/workflows/tf.yml
name: terraform
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
plan:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # lets this job ask GitHub for an OIDC token
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/tf-plan-acme-infra
aws-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@v4
with:
name: tfplan
path: tfplan
retention-days: 1
apply:
needs: plan
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production # required reviewers gate this job
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: tfplan
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/tf-apply-acme-infra-prod
aws-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.

terminal
$ aws sts get-caller-identity
output
{
"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.

OIDC removes the stored key, not the blast radius
Federation takes away the thing worth stealing. It does not put a fence around the job. Anyone who can push a branch to this repository can edit the workflow and run code of their own inside the plan job, holding the plan role for that hour. Two things keep that survivable: the plan role can only read, and a pull request from a fork gets no token at all, because GitHub withholds 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.

terminal
$ 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"
output
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.

policy.rego
# conftest reads the "main" package unless you tell it otherwise.
package main
import rego.v1
deny 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 list
not stmt.Condition.StringEquals["token.actions.githubusercontent.com:sub"]
msg := sprintf("%s trusts GitHub OIDC without pinning sub", [r.address])
}
terminal
$ terraform show -json tfplan > plan.json
$ conftest test --policy policy.rego plan.json
output
FAIL - plan.json - main - aws_iam_role.ci_admin trusts GitHub OIDC without pinning sub
1 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.

Quick check
01Your apply role trusts one subject, 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?
Incorrect — STS reads the claims inside the token, not the ARN you typed into the YAML. Naming a role is a request; the trust policy decides whether the request is granted.
Incorrect — There is no fallback identity in this pipeline. A denied AssumeRoleWithWebIdentity leaves the job holding no AWS credentials at all, so Terraform stops before it touches a single resource.
Correct — GitHub only writes the environment form of the subject when the job declares that environment. Remove the declaration and the claim changes shape, and the apply role was pinned to one exact string. That is why the reviewer click is a credential gate rather than a courtesy.
Incorrect — Nothing stops anyone editing a workflow file. Environment rules gate a job while it runs; keeping the edit out needs branch protection or CODEOWNERS review on the workflow path.
02You run 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?
Correct — An AKIA key sitting in a secret store works for whoever holds the string, with no expiry to save you. The ASIA session in the log is the federated one, worthless by tomorrow. Searching logs and secret stores for both prefixes tells you which kind a pipeline really runs on.
Incorrect — The mapping runs the other way round. The credentials action hands back an ASIA session that dies within the hour, and the AKIA key is the leftover you can delete once nothing depends on it.
Incorrect — Neither prefix encodes geography, and one of the two is not permanent. The letters mark credential type: an IAM user's key, or a temporary session from an assumed role.
Incorrect — The key id says nothing about MFA. An unattended runner never answers a prompt anyway, which is part of why the long-lived key is the dangerous one to leave lying around.
03A role's trust policy allows 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?
Incorrect — One issuer serves every account on the platform and that audience string is identical for all of them. It is also why Google Cloud refuses to create a GitHub pool without an attribute condition attached.
Incorrect — This particular call is unsigned, which is exactly what lets you run the negative test from a laptop with no AWS credentials configured. The token and the ARN are all a caller needs.
Incorrect — Pinning the wrong subject fails closed and costs you a morning. Omitting it fails open: the one condition present matches everybody, so the absent one waves them through.
Correct — A role ARN travels through logs, screenshots, tickets and shared modules, and was never secret. Pin the whole subject string, and if you have to wildcard part of it, wildcard the branch and never the repository.

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.

Related