Terraform is the most privileged workload in your infrastructure. It creates VPCs, modifies IAM policies, provisions databases, configures encryption keys, and manages DNS. The service account running terraform apply typically holds near-administrator permissions across your entire cloud environment.
And yet, that credential sits idle 99% of the time. Terraform runs for a few minutes during deployments, then the credential does nothing until the next run — while retaining full infrastructure-admin access around the clock. That's not a tool with elevated access. That's a permanent backdoor waiting for someone to find it.
JIT for Terraform means the IaC runner receives elevated credentials only during plan and apply phases, scoped to what the specific run needs, and those credentials vanish the moment the run completes. Between runs, the Terraform identity holds zero standing privilege.
Why Terraform Credentials Are Uniquely Dangerous
Terraform occupies a special position in your attack surface for several reasons:
Maximum blast radius. Terraform's permissions aren't scoped to "deploy a container" or "write to a database." They span your entire infrastructure graph — networking, compute, storage, identity, secrets management, DNS, logging. A compromised Terraform credential is a compromised cloud account.
State file exposure. Terraform state files contain the full configuration of every managed resource, often including sensitive outputs (database passwords, API keys, private IPs). The backend credential that reads/writes state is itself a high-value target. State backends (S3, GCS, Azure Blob) require their own access controls.
Long-lived by default. Whether it's a service account JSON key in a CI variable, an AWS access key in a secrets manager, or an Azure client secret, Terraform credentials are typically provisioned once during initial setup and never rotated. Teams are afraid to touch them because "if the key breaks, nobody can deploy."
Broad trust assumptions. Most teams grant Terraform a single monolithic role — AdministratorAccess in AWS, roles/editor or roles/owner in GCP, Contributor or Owner in Azure — because Terraform "needs to manage everything." The reasoning is pragmatic: maintaining granular permissions for every resource type Terraform might touch is exhausting. So teams over-provision and move on.
Supply chain target. Terraform providers are downloaded from registries. If a malicious provider (or a compromised version of a legitimate provider) executes during terraform init or apply, it inherits whatever credentials the Terraform process holds. A supply chain attack against a Terraform provider is effectively an attack with your infrastructure admin credentials.
The JIT Model for Terraform
The goal is straightforward: Terraform gets elevated access only during active runs, and that access is scoped to the minimum required for the specific operation.
Between runs: The Terraform identity (whether it's a CI pipeline service account, a human operator, or an automation platform) holds zero infrastructure-write permissions. It may retain read access to the state backend — or even that can be JIT-gated.
During runs: The pipeline or platform elevates the identity for the duration of plan/apply, then revokes elevation immediately upon completion (or failure).
The lifecycle:
[Idle: No permissions]
→ Trigger (PR merge, manual dispatch, schedule)
→ Authenticate (OIDC federation, Vault lease, assume-role)
→ [Elevated: Scoped infrastructure permissions]
→ terraform init → plan → apply
→ [Revoke: Credentials expire / role assumption ends]
→ [Idle: No permissions]
Implementation: GitHub Actions + AWS OIDC (Per-Run Credentials)
The cleanest implementation for AWS uses OIDC federation so the Terraform pipeline never holds a static credential at all.
IAM role trust policy — scoped to the Terraform workflow on main branch:
{
"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:your-org/infra:ref:refs/heads/main"
}
}
}
]
}
GitHub Actions workflow:
name: Terraform Apply
on:
push:
branches: [main]
paths: ['terraform/**']
jobs:
apply:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: read
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/TerraformApplyRole
aws-region: us-east-1
role-session-name: tf-apply-${{ github.run_id }}
role-duration-seconds: 1800 # 30 minutes max
- uses: hashicorp/setup-terraform@v3
- run: terraform init
working-directory: terraform/
- run: terraform apply -auto-approve
working-directory: terraform/
What this achieves:
- No static credentials stored anywhere. The pipeline authenticates via OIDC token.
- The credential lives for 30 minutes maximum, regardless of job completion.
- Only the main branch can assume the apply role. PR branches can assume a separate plan-only role.
- CloudTrail logs show tf-apply-12345678 as the session name, directly correlating to the GitHub Actions run.
- Splitting Plan and Apply Permissions
- One monolithic Terraform role is the easy path, but JIT enables something better: separate roles for plan vs. apply.
Plan role (used on PRs):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ec2:Describe*",
"rds:Describe*",
"s3:GetBucket*",
"s3:ListBucket",
"iam:GetRole",
"iam:GetPolicy",
"iam:ListRolePolicies"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::terraform-state-bucket/*"
}
]
}
Apply role (used only on main, after merge):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ec2:*",
"rds:*",
"s3:*",
"iam:*",
"lambda:*"
],
"Resource": "*"
}
]
}
Trust policy for the plan role allows PR branches:
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:your-org/infra:pull_request"
}
Now a compromised PR workflow — even if an attacker submits a malicious PR that executes arbitrary code in the plan step — only has read access. It cannot modify infrastructure. The write-capable role is only assumable from the main branch after code review and merge.
Implementation: Vault-Brokered AWS Credentials for Terraform
For teams running Terraform outside of GitHub Actions (GitLab, Jenkins, Atlantis, or local runs), Vault's AWS secrets engine provides dynamic, lease-based credentials.
Configure Vault's AWS secrets engine:
vault secrets enable aws
vault write aws/config/root \
access_key=AKIAIOSFODNN7EXAMPLE \
secret_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY \
region=us-east-1
# Create a role that generates STS credentials with a 30-minute TTL
vault write aws/roles/terraform-apply \
credential_type=assumed_role \
role_arns=arn:aws:iam::123456789012:role/TerraformApplyRole \
default_sts_ttl=1800 \
max_sts_ttl=3600
Terraform pipeline requests credentials from Vault before running:
# Authenticate to Vault (using the pipeline's own identity — JWT, AppRole, etc.)
export VAULT_TOKEN=$(vault write -field=token auth/jwt/login role=terraform-ci jwt=$CI_JOB_JWT)
# Get short-lived AWS credentials
CREDS=$(vault read -format=json aws/creds/terraform-apply)
export AWS_ACCESS_KEY_ID=$(echo $CREDS | jq -r '.data.access_key')
export AWS_SECRET_ACCESS_KEY=$(echo $CREDS | jq -r '.data.secret_key')
export AWS_SESSION_TOKEN=$(echo $CREDS | jq -r '.data.security_token')
# Run Terraform with ephemeral credentials
terraform init && terraform apply -auto-approve
# Revoke the lease immediately after completion (don't wait for TTL)
vault lease revoke $(echo $CREDS | jq -r '.lease_id')
The advantage over raw OIDC: Vault adds a policy layer between the pipeline and the cloud credential. You can enforce additional conditions — time-of-day restrictions, rate limiting, required metadata — that OIDC federation alone doesn't provide. Vault's audit log also gives you a second audit trail independent of CloudTrail.
Implementation: Terraform Cloud/HCP Dynamic Credentials
HashiCorp's managed Terraform platform (Terraform Cloud and HCP Terraform) has native dynamic credential support, eliminating static provider credentials entirely.
Configure in workspace settings:
# In your Terraform Cloud workspace variables:
# TFC_AWS_PROVIDER_AUTH = true
# TFC_AWS_RUN_ROLE_ARN = arn:aws:iam::123456789012:role/TerraformCloudRole
Trust policy for the role:
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/app.terraform.io"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"app.terraform.io:aud": "aws.workload.identity"
},
"StringLike": {
"app.terraform.io:sub": "organization:your-org:project:production:workspace:infra-prod:run_phase:apply"
}
}
}
Note the granularity in the subject claim: you can restrict the role to a specific organization, project, workspace, and even run phase (plan vs. apply). A plan-phase run cannot assume the apply-phase role.
Protecting the State Backend
Terraform state is often overlooked in JIT discussions, but the state backend credential is itself a high-privilege asset. State files contain:
- Resource IDs and ARNs for every managed resource.
- Sensitive outputs (passwords, keys, endpoints) unless explicitly marked with sensitive = true (and even then, they're in the raw state JSON).
- Enough information to map your entire infrastructure topology.
JIT the state backend access:
- The plan/apply role includes state read/write permissions only during active runs.
- Between runs, no identity has write access to the state bucket.
- Enable versioning on the state bucket so accidental or malicious state corruption is recoverable.
- Use a separate, narrower role for terraform state pull operations (read-only, no write, short TTL).
Lock down the bucket policy:
{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::terraform-state-bucket",
"arn:aws:s3:::terraform-state-bucket/*"
],
"Condition": {
"StringNotLike": {
"aws:PrincipalArn": [
"arn:aws:iam::123456789012:role/TerraformApplyRole",
"arn:aws:iam::123456789012:role/TerraformPlanRole"
]
}
}
}
Only the Terraform roles (which are themselves only active during runs) can access state. No human, no other service, no break-glass role without explicit exception.
Handling Drift Detection and Scheduled Runs
Terraform isn't only triggered by deployments. Many teams run scheduled drift detection (terraform plan on a cron) to catch manual changes. These scheduled runs also need credentials — but they should be plan-only, never apply.
# Scheduled drift detection — plan-only role, short TTL
name: Drift Detection
on:
schedule:
- cron: '0 */6 * * *' # Every 6 hours
jobs:
drift-check:
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/TerraformPlanOnlyRole
role-duration-seconds: 900 # 15 minutes
- run: terraform plan -detailed-exitcode
If drift is detected, the workflow alerts the team but cannot remediate — the plan-only role has no write access. A human or an approved apply workflow handles remediation.
The Operational Payoff
After implementing JIT for Terraform:
- Between deployments, no identity in your environment has infrastructure-write access. An attacker who compromises your CI system at rest finds nothing to exploit.
- Plan and apply are cryptographically separated. A malicious PR cannot modify infrastructure, even if it achieves code execution during the plan phase.
- Credential theft is bounded to minutes. A leaked session token from a Terraform run expires before an attacker can operationalise it.
- Audit attribution is precise. Every infrastructure change correlates to a specific run ID, commit SHA, and triggering actor.
- Supply chain risk is contained. A compromised Terraform provider executing during a 30-minute apply window can cause damage, but it cannot establish persistence — the credential it's abusing will be gone in minutes, and any IAM backdoors it creates will be caught by drift detection on the next scheduled plan.
Terraform is your most powerful automation. It deserves your most rigorous access controls — not your most permissive ones.
Top comments (0)