Sachin Chaurasiya

Infrastructure as Code Part 1 of 3 · Infrastructure as Code Security

Secure Terraform Foundations: Pinning, State, Secrets and Review

The security foundations of a Terraform repository: pinned versions with a committed lock file, remote state with locking and access control, secrets kept out of source, least-privilege plan and apply roles.

Author
Sachin Chaurasiya
Sachin Chaurasiya
Published
Reading time
11 min read
Difficulty
intermediate

Reviewed Tested with Terraform 1.14.9 (hashicorp/terraform:1.14 image), AWS provider 6.64.0, Docker 29.1

On this page

Overview

Terraform turns an infrastructure change into a text diff, which is what makes it reviewable. The same property makes a Terraform repository a high-value target: a change that opens a security group or grants a policy is one approved merge request away from production, and the state file behind it contains the real values of everything the configuration created. The foundations below are the habits that keep that diff trustworthy before any scanner is involved. Part 2 adds the scanner; part 3 puts it in the merge request.

Terraform commands in this part were run with the hashicorp/terraform:1.14 image (Terraform 1.14.9) against the configuration from part 2. plan and apply against a real account were not run; where a behaviour depends on that, the text says so.

Prerequisites

  • Terraform basics: resources, variables, outputs, plan and apply
  • Docker, to run Terraform from its image, or Terraform 1.1x installed
  • A Git repository to keep the configuration in

Pin everything that can move

A Terraform run has three moving inputs: the Terraform binary, the providers, and any modules. Unpinned, each can change between two runs of the same commit, and a provider release can change resource defaults (what a bucket’s ACL means, what a security group allows) without a line of your code changing.

terraform {
  required_version = "~> 1.14"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = "eu-west-1"
}

~> 6.0 allows 6.x but not 7.0. The exact version is then recorded by terraform init in the dependency lock file, and that file is what makes the choice reproducible:

docker run --rm -v "$PWD:/tf" -w /tf hashicorp/terraform:1.14 init -backend=false -input=false
Terraform has been successfully initialized!
provider "registry.terraform.io/hashicorp/aws" {
  version     = "6.64.0"
  constraints = "~> 6.0"
  hashes = [
    # one h1: and several zh: checksums per platform
  ]
}

Commit .terraform.lock.hcl. With it, every init on every machine selects 6.64.0 and verifies the provider package against the recorded checksums; an upgrade is a deliberate terraform init -upgrade in its own merge request. Without it, the runner picks whatever 6.x is newest today.

Modules get the same treatment. Registry modules take a version constraint; Git sources take a ref, and a tag can be moved, so a commit SHA is the strict option:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.21"
}

module "internal_baseline" {
  source = "git::https://gitlab.example.com/platform/tf-baseline.git?ref=3f2c1d9e4b7a6c5d8e9f0a1b2c3d4e5f6a7b8c9d"
}

A module is code that runs with your credentials. Treat adding one like adding a dependency: read what it creates, pin it, and prefer modules your organisation owns or has reviewed over the most-starred one.

State is sensitive

The state file records every attribute of every resource Terraform manages, including ones the configuration never wrote down: generated passwords, private keys returned by a provider, endpoint addresses, account IDs. Two consequences follow.

State never goes in Git. Even a private repository is cloned to laptops and CI runners. Add it to .gitignore along with the other files that carry secrets or machine-specific paths:

# Terraform state and workspace
*.tfstate
*.tfstate.*
.terraform/
crash.log

# Variable files with real values (commit *.tfvars.example instead)
*.tfvars
*.tfvars.json
!*.auto.tfvars.example

# Saved plans contain the same values state does
*.tfplan

.terraform.lock.hcl is deliberately not in that list; it contains checksums, not secrets, and must be committed.

State lives in a remote backend with locking and access control. For AWS that is an S3 bucket with versioning, encryption and a public-access block (the same controls part 2 will make Checkov enforce), and, from Terraform 1.10, native locking on the bucket itself:

terraform {
  backend "s3" {
    bucket       = "example-terraform-state"
    key          = "network/prod/terraform.tfstate"
    region       = "eu-west-1"
    encrypt      = true
    use_lockfile = true # S3-native locking (Terraform 1.10+); no DynamoDB table needed
  }
}

Access to that bucket is access to every secret in the state. Grant it to the CI role and to the few people who run Terraform by hand, and to nobody else; separate the plan role (read state) from the apply role (read and write state) as described below. This backend block was not initialised in these runs because no AWS account was used; the syntax is from the Terraform 1.14 documentation.

Keep secrets out of source, and where possible out of state

Three layers, in order of preference.

Do not have the secret at all. Many providers can generate and manage a credential so that it never appears in configuration, variables or (for some resources) state. For RDS:

resource "aws_db_instance" "app" {
  # ...
  manage_master_user_password = true # AWS generates and rotates it in Secrets Manager
}

The application reads the password from Secrets Manager at runtime with its own IAM role. Nothing in the repository or the state file knows it.

Pass it in, marked sensitive. When a value must be supplied, declare it sensitive so Terraform redacts it from plan and apply output and from terraform output:

variable "api_token" {
  description = "Token for the monitoring provider"
  type        = string
  sensitive   = true
}

Supply it from the environment (TF_VAR_api_token) or a CI variable, never from a committed .tfvars. Note what sensitive does not do: the value is still stored in state in plain text, and a resource that uses it may echo it into its own attributes. Redaction is a display property. Terraform 1.10 added ephemeral values and 1.11 added write-only resource arguments to keep a value out of state entirely; the provider must support them for the resource in question, and they were not exercised here.

Reference it, do not copy it. For values that already live in a secret manager, read them with a data source at plan time rather than pasting them into variables. The data source result still lands in state, so this is a convenience for rotation, not a fix for state exposure; the fix for state exposure is the backend access control above.

Least privilege for the credentials Terraform runs with

Terraform can only do what its credentials allow, and “AdministratorAccess on the CI role” is the most common way a small mistake in a .tf file becomes a large one. Split the roles by what each step needs:

StepNeedsRuns on
fmt, validate, CheckovNothing. No credentials, no network beyond the provider download.Every merge request
planRead the state; describe resources (ec2:Describe*, s3:Get*, rds:Describe*)Every merge request
applyRead and write the state; create, update, delete the resources in scopeProtected branch only, after review

Give the plan role read-only permissions and the apply role a policy scoped to the services this repository manages. Prefer short-lived credentials (OIDC from the CI platform to an IAM role) to long-lived keys stored as CI variables; a leaked static key is valid until someone notices, a leaked OIDC token expires in minutes.

Without credentials at all, plan stops at the provider, which is the behaviour a merge request from a fork should get:

docker run --rm -v "$PWD:/tf" -w /tf hashicorp/terraform:1.14 plan -input=false \
  -var office_cidr=203.0.113.0/24 -var vpc_id=vpc-0123456789abcdef0
Planning failed. Terraform encountered an error while generating this plan.
│ Error: No valid credential sources found

Repository hygiene and review workflow

The review is the control. What reaches the reviewer decides what they can catch.

Format and validate on every change. fmt -check fails on unformatted files so that diffs show intent, not whitespace; validate catches wrong attribute names and type errors before a plan is attempted. Both ran clean on the part 2 configuration:

docker run --rm -v "$PWD:/tf" -w /tf hashicorp/terraform:1.14 fmt -check -diff
docker run --rm -v "$PWD:/tf" -w /tf hashicorp/terraform:1.14 validate
Success! The configuration is valid.

Plan in the merge request, apply from the protected branch. The plan output is the review artifact. Post it (or attach it) to the merge request so the reviewer reads + resource, ~ update in-place and, above all, -/+ destroy and then create replacement lines, not just HCL. Save the plan and apply that file, so the apply cannot differ from what was reviewed:

terraform plan -input=false -out=tfplan
terraform show -no-color tfplan > plan.txt   # attach to the merge request
# after merge, on main:
terraform apply -input=false tfplan

A saved plan is only valid against the state it was made from; if someone applied in between, apply refuses and you plan again. That is the correct outcome.

Protect main and require review. One approver who did not write the change, no direct pushes, and apply only from a pipeline on that branch. This is the same shape as the CI/CD Engineering path uses for application deploys, for the same reason: exactly one way for a change to reach production.

Keep the repository small and named. One repository (or one root module directory) per blast radius: network and accounts separately from application infrastructure, production separately from staging. A state file that holds everything is a state file whose apply role can change everything.

Security Considerations

  • A provider is code from the registry that runs with your credentials. The lock file’s checksums verify the download; the version constraint decides which code you accept. Keep both under review.
  • terraform destroy and a plan with replacements are the destructive operations. Require a plan review for both and consider prevent_destroy lifecycle rules on stateful resources.
  • Local Terraform runs with personal credentials bypass every control above. Make the pipeline the fast path so nobody needs the slow one.

Troubleshooting

SymptomCauseFix
init selects a different provider version on the runner than locallyLock file not committedCommit .terraform.lock.hcl; run terraform providers lock for the runner’s platform
Error: Inconsistent dependency lock fileConstraint changed without updating the lockterraform init -upgrade in a dedicated merge request
plan shows a sensitive value in clearVariable not marked sensitive, or value surfaced by a resource attributeMark it; move the secret to a managed/ephemeral mechanism
apply refuses a saved planState changed since the plan was madePlan again; this is the safeguard working
Two pipelines apply at once and corrupt stateNo state lockingEnable use_lockfile (S3) or the backend’s locking mechanism; serialise apply jobs

Production Recommendations

  • Pin Terraform, providers and modules; commit the lock file; upgrade deliberately.
  • Remote, encrypted, versioned, locked state with access granted to roles, not people.
  • Prefer provider-managed and ephemeral secrets; mark the rest sensitive; never commit .tfvars with values.
  • Separate plan and apply credentials; use OIDC; apply only from the protected branch, from a saved plan.
  • Split state by blast radius.

Conclusion

None of this is a scanner. It is the shape of a repository whose diffs can be trusted: pinned inputs, protected state, secrets that are not in the diff, credentials that cannot do more than the change needs, and a review that sees the plan. Part 2 adds the tool that reads those diffs for the mistakes a human reviewer will miss.

References

Keep reading