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's trust policy pins the subject to `repo:acme/infra:environment:production`, and the production environment in GitHub requires a reviewer. Someone edits the workflow to delete the `environment: production` line from the apply job so it stops waiting for approval, and that edit lands on `main`. What happens the next time the apply job runs?
Incorrect — The workflow file decides which role the job asks for. It has no say in which role AWS agrees to hand over. Asking is not getting.
Correct — Dropping the environment changes the sub claim GitHub writes into the token, and that trust policy accepts only the environment form. The approval is enforced by AWS, not by the pipeline file.
Incorrect — GitHub does not protect workflow files that way. Environment rules gate a job at run time; branch protection and required reviews are separate controls you have to switch on yourself.
Incorrect — There is no fallback path. A failed AssumeRoleWithWebIdentity leaves the job with no AWS credentials at all, so Terraform stops before it touches a single resource.
02While auditing a pipeline you find two AWS access keys: one begins `AKIA...` and the other begins `ASIA...`. What does the prefix tell you?
Correct — IAM-user access keys begin `AKIA` and never expire, while assumed-role credentials begin `ASIA` and lapse after roughly an hour, which is why those prefixes are worth grepping logs and secret stores for.
Incorrect — this reverses the two: `AKIA` is the long-lived user key and `ASIA` is the short-lived role session.
Incorrect — `ASIA` credentials are temporary, not long-lived, and the prefixes denote credential type rather than region.
Incorrect — the prefixes say nothing about MFA; they distinguish a permanent user key from a temporary role session.
03An engineer writes an AWS role trust policy for GitHub OIDC (OpenID Connect) that checks `token.actions.githubusercontent.com:aud` equals `sts.amazonaws.com` but includes no `sub` (subject) condition at all. The role ARN (Amazon Resource Name) appears in a build log. What is the practical exposure?
Incorrect — a role ARN is not a secret; it routinely leaks into build logs, screenshots, tickets and shared modules.
Incorrect — the `aud` value is identical for every GitHub Actions job on the platform, so it scopes nothing to your organization.
Correct — every Actions job can request a token with `aud = sts.amazonaws.com`, so with only the non-secret ARN and no `sub` check an unrelated repository can assume your role.
Incorrect — it fails open: because `aud` matches for everyone, the missing `sub` lets any workflow through rather than blocking them.

Related