TerraformXXII · CI/CD for Production TerraformCI/CD credentials
CI/CD Credentials for Terraform
What you'll learn
- Explain why long-lived cloud access keys in CI are a production antipattern
- Configure OIDC federation between GitHub Actions and AWS, GCP, or Azure
- Distinguish short-lived STS tokens from long-lived access keys in the audit trail
- Apply a rotation cadence that matches the lifetime of the credentials
- Recognise the operational signals that indicate a leaked CI credential
Prerequisites
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13
The CI pipeline needs cloud credentials. The question is not whether; it is what kind, how long they live, and what the audit trail shows when one is misused. The production answer in 2026 is OIDC federation: the CI runner exchanges a short-lived signed token for a short-lived STS token at the start of each job. No access keys. No static secrets. No rotation calendar.
This lesson covers the problem with long-lived keys, the OIDC pattern that replaces them, the per-cloud wiring (AWS, GCP, Azure), the audit trail that OIDC leaves, and the operational signals that indicate a leak.
The problem with long-lived keys
A long-lived access key in a CI secret looks like this:
Repository: runbook/terraform-modules
Secret name: AWS_ACCESS_KEY_ID
Secret value: AKIAEXAMPLE...
The key has an IAM policy attached to it. The policy is the authorisation boundary; the key is the credential. If the secret leaks (and it will — every operator who has ever worked on the repo can read it; every GitHub Actions workflow that targets the repo can read it; every fork PR can read it through a malicious workflow), the attacker has the credentials and the policy.
The blast radius depends only on the policy. If the policy
is AdministratorAccess, the attacker owns the account. If
the policy is PowerUserAccess, the attacker owns the
account minus IAM. If the policy is least-privileged (the
correct case), the attacker can do what the CI can do. Either
way, the credential does not expire for the lifetime of the
key. Rotation is a separate operation that someone has to
remember to do.
Three production failures follow from this pattern:
- Forgotten keys. A service account key created in 2022 is still in use in 2026. The team that created it left. The key has not been rotated. The CI uses it because the secret still works.
- Over-privileged policies. To stop the pipeline failing, someone widens the policy. The wider the policy, the worse the leak.
- No federation trail. CloudTrail shows
AccessKeyId: AKIAEXAMPLE...in every entry. The key does not say who used it, only which key. The audit trail stops at the credential; the human or the workflow that used it is inferred from context.
OIDC federation, the mental model
OIDC federation removes the long-lived key. The CI runner authenticates to the cloud as itself (as a workload identity) using a signed JWT issued by the CI platform. The cloud verifies the JWT signature, checks the claims, and issues a short-lived STS token (or the cloud’s equivalent) for the role the workload is allowed to assume.
GitHub Actions job starts
|
v
CI platform mints a JWT signed with its OIDC key
| claims: sub=repo:runbook/terraform-modules:ref:refs/heads/main
| aud=sts.amazonaws.com
| exp=now + 15 minutes
v
Runner calls sts:AssumeRoleWithWebIdentity
|
v
AWS verifies the JWT, checks the trust policy,
matches the claims against the IAM role's
`Condition` block, returns STS credentials
| AccessKeyId: ASIA... (note: ASIA, not AKIA)
| SecretAccessKey: ...
| SessionToken: ...
| Expiration: now + 1 hour
v
Runner exports AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY,
AWS_SESSION_TOKEN for the duration of the job
|
v
Job ends. The STS credentials expire. The next job mints
a new JWT and gets new STS credentials.
Three properties matter:
- No static secret. The JWT is signed by GitHub’s OIDC key (publicly known) and is valid for the duration of the job. There is nothing to leak that persists past the job.
- Trust policy gates the role. The IAM role’s trust
policy says “only allow
AssumeRoleWithWebIdentityfrom a JWT withsubmatchingrepo:runbook/terraform-modules:*andaudmatchingsts.amazonaws.com”. Anything else is rejected. - The audit trail is per-job. CloudTrail’s
AssumeRoleWithWebIdentityevent includes the JWT claims. Thesubclaim says which repo and which branch the request came from. The audit trail identifies the workflow, not the credential.
The GitHub Actions wiring
The job that uses OIDC needs two things in its workflow YAML: the OIDC permission, and the action that performs the exchange.
jobs:
plan:
runs-on: ubuntu-latest
permissions:
id-token: write # required to mint the OIDC JWT
contents: read # required to checkout the repo
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/terraform-plan
aws-region: eu-west-2
- run: terraform plan -out=tfplan
The permissions: id-token: write block is the trigger. Without
it, configure-aws-credentials cannot mint the JWT and the
job errors with Could not assume role with OIDC. The action
itself handles the AssumeRoleWithWebIdentity call and
exports the STS credentials into the runner environment.
The AWS trust policy
The role the runner assumes is a normal IAM role with a trust policy that allows the GitHub OIDC provider:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:runbook/terraform-modules:*"
}
}
}
]
}
The Condition block is the production control. Without it,
the role is assumable by any GitHub Actions job in any repo
in any org that shares the OIDC provider configuration. With
it, only jobs in the runbook/terraform-modules repo can
assume the role.
Tighten further by pinning to a branch:
"StringEquals": {
"token.actions.githubusercontent.com:sub":
"repo:runbook/terraform-modules:ref:refs/heads/main"
}
Or pin to a specific workflow file (highest assurance):
"StringEquals": {
"token.actions.githubusercontent.com:job_workflow_ref":
"runbook/terraform-modules/.github/workflows/terraform.yml@refs/heads/main"
}
The GCP wiring
GCP uses workload identity federation. The pattern is the same: the CI runner presents a JWT, GCP validates it, and issues short-lived credentials.
# 1. Create the workload identity pool
gcloud iam workload-identity-pools create github-pool \
--location=global \
--display-name="GitHub Actions pool"
# 2. Create the OIDC provider for GitHub
gcloud iam workload-identity-pools providers create-oidc github \
--location=global \
--workload-identity-pool=github-pool \
--issuer-uri="https://token.actions.githubusercontent.com" \
--attribute-mapping="google.subject=assertion.sub,
attribute.actor=assertion.actor,
attribute.repo=assertion.repository" \
--condition-expression="assertion.repository == 'runbook/terraform-modules'"
# 3. Bind the pool to a GCP service account
gcloud iam service-accounts add-iam-policy-binding \
terraform-runner@my-project.iam.gserviceaccount.com \
--role=roles/iam.workloadIdentityUser \
--member="principalSet://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/attribute.repo/runbook/terraform-modules"
# 4. In the workflow
- id: auth
uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/providers/github
service_account: terraform-runner@my-project.iam.gserviceaccount.com
The Terraform provider picks up the credentials automatically
via the GOOGLE_OAUTH_ACCESS_TOKEN environment variable
exported by the action.
The Azure wiring
Azure uses workload identity federation with Entra ID (previously Azure AD):
# APP_OBJECT_ID is the app registration's object id - not its appId - from
# az ad app list --display-name terraform-ci --query '[].id' -o tsv
APP_OBJECT_ID=9c1f2e6b-4d3a-4c8e-9f21-7b5a0d6e8c14
# 1. Register the GitHub OIDC issuer in Entra ID
az ad app federated-credential create \
--id "$APP_OBJECT_ID" \
--parameters '{
"name": "github-actions",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:runbook/terraform-modules:ref:refs/heads/main",
"audience": "api://AzureADTokenExchange"
}'
# 2. In the workflow
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
enable-OIDC: true
The Terraform azurerm provider picks up the credentials
from the standard Azure SDK environment variables.
The audit trail
The CloudTrail entry for an OIDC-federated call:
{
"eventTime": "2026-08-13T11:42:07Z",
"eventName": "AssumeRoleWithWebIdentity",
"userIdentity": {
"type": "AssumedRole",
"principalId": "AROAEXAMPLE:AssumeRoleWithWebIdentity",
"arn": "arn:aws:sts::123456789012:assumed-role/terraform-plan/AssumeRoleWithWebIdentity",
"accountId": "123456789012",
"sessionContext": {
"sessionIssuer": {
"type": "Role",
"arn": "arn:aws:iam::123456789012:role/terraform-plan"
},
"webIdFederationData": {
"federatedProvider": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com",
"attributes": {
"sub": "repo:runbook/terraform-modules:ref:refs/heads/main",
"aud": "sts.amazonaws.com",
"repository": "runbook/terraform-modules"
}
}
}
},
"requestParameters": {
"roleArn": "arn:aws:iam::123456789012:role/terraform-plan",
"roleSessionName": "AssumeRoleWithWebIdentity"
}
}
Compare to a long-lived key:
{
"eventTime": "2026-08-13T11:42:07Z",
"eventName": "AssumeRole",
"userIdentity": {
"type": "IAMUser",
"userName": "terraform-runner",
"accessKeyId": "AKIAEXAMPLE..."
}
}
The OIDC entry says which repo and which branch used the role. The long-lived-key entry says which access key used it, but the key does not say who or what. When the incident review asks “which CI job created this resource”, the OIDC entry answers in one line. The access-key entry requires correlating timestamps against GitHub Actions logs.
Why never paste an access key into a repo secret
A repo secret is a UI surface, not a security control. The following are all true of every long-lived access key in a repo secret:
- It is visible to every maintainer of the repo.
- It is visible to every GitHub Actions workflow that runs in the repo, including from forks.
- It is visible to any third-party action that reads
process.env. - It persists in the secret store until manually rotated or removed.
- It does not expire on its own.
- It does not identify which workflow used it in CloudTrail.
The OIDC path removes all six properties. The federation is the production control.
Rotation cadence
The credential lifetime determines the rotation cadence.
| Credential | Lifetime | Rotation cadence |
|---|---|---|
| Long-lived access key | Until manually rotated | 90 days maximum; shorter for high-privilege keys |
| OIDC STS session token | 1 hour (job duration) | Per job, automatically |
| GCP short-lived token | 1 hour | Per job, automatically |
| Azure federated token | 1 hour | Per job, automatically |
| HashiCorp Vault dynamic secret | Configurable, typically 1 hour | Per request or per lease |
The OIDC STS token expires when the job ends. There is no rotation calendar because there is no static credential to rotate. The operational discipline shifts from “rotate the key” to “audit the trust policy”.
Operational signals of a leaked credential
Three signals indicate a possible credential leak:
- STS usage from a new geo or IP. CloudTrail
AssumeRoleWithWebIdentityfrom an address that does not match GitHub’s published IP ranges. GitHub publishes the ranges athttps://api.github.com/meta; deny anything outside them at the SCP level. - STS usage from a repo not in the trust policy. Even
if the trust policy is broad, a usage pattern that hits
the role from a repo you do not recognise is an incident.
Alert on every
AssumeRoleWithWebIdentityand read thesubclaim. - Apply calls outside business hours or from a
new actor. Even with a tight trust policy, the
actorclaim is recorded. Alert on activity that does not match the normal pattern.
For long-lived keys, the corresponding signal is harder to read because the access key does not say who used it. You have to correlate CloudTrail timestamps against GitHub Actions logs. The OIDC entry does this for free.
Production guidance
- Use OIDC for every cloud provider that supports it. AWS, GCP, Azure, and most modern clouds have a federation path.
- Pin the trust policy to the specific repo, branch, and workflow file where you can. Use the strictest condition the workflow design allows.
- Separate the plan role from the apply role. The plan role is read-only where the language allows; the apply role is least-privileged for the resources the plan proposes.
- Keep the trust policy under source control. A change to the trust policy is a security-sensitive change; review it like code.
- Alert on every
AssumeRoleWithWebIdentityfrom a repo, branch, or workflow that is not in the trust policy. The alert is cheap; the missing alert is expensive. - If a long-lived access key exists for legacy reasons, rotate it to OIDC before its next manual rotation. Do not let the rotation become a reason to keep the key.
Validation commands
Confirm the federation is working end-to-end:
# 1. From a workflow job that has id-token: write
aws sts get-caller-identity
# Output:
# {
# "UserId": "AROAEXAMPLE:AssumeRoleWithWebIdentity",
# "Account": "123456789012",
# "Arn": "arn:aws:sts::123456789012:assumed-role/terraform-plan/AssumeRoleWithWebIdentity"
# }
# 2. Confirm the role's trust policy
aws iam get-assume-role-policy \
--role-name terraform-plan \
--query 'AssumeRolePolicyDocument.Statement[0].Condition'
# 3. Confirm the access key is an ASIA (session), not an AKIA (static)
echo "$AWS_ACCESS_KEY_ID" | head -c 4
# ASIA... correct: STS session token
# AKIA... wrong: this is a long-lived key, OIDC is not configured
The ASIA prefix is the visual confirmation that OIDC is
working. The AKIA prefix is the visual confirmation that
the OIDC step did not run and a long-lived key is in use.
Production failure modes
-
Trust policy too broad. The condition allows any
repo:*in the org. A workflow in a low-trust repo can assume the role. The fix is the narrowsuborjob_workflow_refcondition shown above. -
OIDC step silently failed; a backup access key was used. The runner has both OIDC and a long-lived key in the environment. OIDC failed (network blip, misconfigured trust policy). The
terraform planran with the static key. The audit trail now showsAKIA, notASIA, and nobody noticed. The fix is to fail the job on OIDC failure and to require that long-lived keys are not present in the runner environment. -
Role policy too broad. The role has
AdministratorAccessbecause someone needed it for an emergency fix and never tightened it. Every successful OIDC assumption gives the runner full account access. The fix is least-privilege scoping to the resources the plan proposes, broken out per environment. -
Trust policy not under source control. Someone edited the trust policy in the console to fix a misconfigured
subclaim during an incident, then forgot to revert. The trust policy now allows a different repo. The fix is to manage IAM with Terraform or CloudFormation and to alert on console-side changes to IAM. -
STS token logged. A debug step prints
env | grep AWSto the job log. The session token is in the log for the duration of the artifact retention. The fix is to never log credentials, and to require that log scanning is in place even if a future contributor forgets. -
OIDC enabled but the static key was not removed. Both are in the runner. The OIDC path is used when it works, the static path is used as a fallback. The blast radius is still the static key. The fix is to remove the static key and to confirm
AKIAdoes not appear in the audit trail.
What comes next
The next lesson covers plan artifacts: how the saved plan travels through the pipeline, why the apply must execute exactly what was reviewed, and why a plan file is itself a sensitive artefact.
Verification
From a GitHub Actions job with id-token: write, run
aws sts get-caller-identity and confirm the Arn is an
assumed-role/.../AssumeRoleWithWebIdentity value and the
access key starts with ASIA. Trigger the same workflow
from a fork PR and confirm the trust policy rejects the
assumption (the job should fail with a credential error).
Trigger the workflow from a branch not in the trust policy
and confirm the same rejection.
Knowledge check · 7 questions
Q1. What is the primary reason long-lived access keys are a production antipattern in CI?
Q2. In GitHub Actions OIDC, which permission must be set on the job?
Q3. An OIDC trust policy must constrain the `sub` or `job_workflow_ref` claim, because allowing any GitHub Actions JWT from the org leaves the role assumable by every workflow in it.
Q4. What is the difference between an `AKIA` and an `ASIA` access key in AWS CloudTrail?
Q5. Which of the following are operational signals of a possible CI credential leak? (Select all that apply.)
Q6. What is the role of the `Condition` block in an OIDC trust policy?
Q7. An operator pastes an AWS access key into a GitHub Actions repo secret to fix a failing OIDC step. The key is AdministratorAccess. What is the blast radius?
Passing score: 75%. Answers are checked in this browser.