November 1, 2026
Terraform Security Explained: How to Write, Scan and Deploy Infrastructure Code Safely
Where Terraform projects leak secrets and create risky infrastructure, and how to secure the code, the state file and the pipeline with…
By Vigneshkumaroffsec
10 min read
Where Terraform projects leak secrets and create risky infrastructure, and how to secure the code, the state file and the pipeline with open-source tools.
By Vignesh Kumar | Security Researcher
In 2019, an attacker stole data on more than 100 million Capital One customers.
The attack combined a misconfigured web application firewall with a cloud role that had far more access than it needed. That role could list and read S3 buckets, which turned one server-side request forgery into a major breach.
Nobody needed to break AWS. The dangerous part was a permission somebody had granted.
Today, permissions like that are often written in Terraform. That is good news and bad news.
The good news is that your infrastructure can now be reviewed, scanned and tested like any other code. The bad news is that one careless line can create the same mistake in every environment, and one leaked state file can expose your secrets.
This article explains how Terraform works in security terms, where it goes wrong, and how to build a secure workflow from the first .tf file to production.
How Terraform works, in security terms
Terraform turns code into real infrastructure in three steps:
- Write: you describe the resources you want in
.tffiles. - Plan: Terraform compares your code with its state and shows what will change.
- Apply: Terraform calls the cloud APIs to make those changes.
Four things make this workflow sensitive:
- Terraform needs powerful credentials. It often can create, change and delete almost anything in the account.
- The state file is a complete record of your infrastructure. It can include passwords and keys in plain text.
- Providers and modules are third-party code. They run on your machine or CI runner with your credentials.
terraform planis not read-only from a security point of view. It runs provider code and can run external programs through data sources.
So securing Terraform means more than scanning .tf files. You need to protect the code, the state, the supply chain and the pipeline.
Where Terraform projects break
These are the most common problems:
- Hardcoded secrets: passwords and keys in
.tfor.tfvarsfiles. - Exposed state: state files in Git, or in a storage bucket that too many people can read.
- Insecure resources: public buckets, open security groups, unencrypted disks and databases.
- Wildcard IAM: policies with
Action = "*"andResource = "*". - Unpinned dependencies: modules and providers that can change without anyone reviewing them.
- Long-lived CI credentials: cloud access keys stored as pipeline secrets.
- No review of the plan: code gets reviewed, but the actual changes do not.
Let's fix each of these, in the order you meet them.
1. Keep Secrets Out of Your Code
The rule is simple: no secret should ever appear in a .tf file or in Git.
This is what not to do:
resource "aws_db_instance" "main" {
identifier = "app-db"
username = "admin"
password = "SuperSecret123!"
}resource "aws_db_instance" "main" {
identifier = "app-db"
username = "admin"
password = "SuperSecret123!"
}Start with a .gitignore that protects the files people commit by accident:
.terraform/
*.tfstate
*.tfstate.*
*.tfvars
*.tfplan
crash.log.terraform/
*.tfstate
*.tfstate.*
*.tfvars
*.tfplan
crash.logNote that .terraform.lock.hcl should be committed. It is not a secret, and we will use it later.
Let the cloud manage the secret
The best secret is one Terraform never sees. On AWS, RDS can create and rotate the master password in Secrets Manager:
resource "aws_db_instance" "main" {
identifier = "app-db"
engine = "postgres"
instance_class = "db.t4g.medium"
allocated_storage = 50
username = "app_admin"
manage_master_user_password = true
storage_encrypted = true
}resource "aws_db_instance" "main" {
identifier = "app-db"
engine = "postgres"
instance_class = "db.t4g.medium"
allocated_storage = 50
username = "app_admin"
manage_master_user_password = true
storage_encrypted = true
}Terraform stores a reference to the secret, not the password.
Understand what sensitive = true does
Marking a variable as sensitive hides it from CLI output:
variable "api_token" {
type = string
sensitive = true
}variable "api_token" {
type = string
sensitive = true
}But it does not encrypt anything. The value is still written to the state file in plain text. The same is true of values read through a data source.
Use ephemeral values and write-only arguments
Terraform 1.10 added ephemeral resources, and 1.11 added write-only arguments. Together, they let a value pass through Terraform without being stored in state or plan files:
ephemeral "aws_secretsmanager_secret_version" "db" {
secret_id = "prod/app/db-password"
}
resource "aws_db_instance" "main" {
identifier = "app-db"
engine = "postgres"
instance_class = "db.t4g.medium"
allocated_storage = 50
username = "app_admin"
password_wo = ephemeral.aws_secretsmanager_secret_version.db.secret_string
password_wo_version = 1
}ephemeral "aws_secretsmanager_secret_version" "db" {
secret_id = "prod/app/db-password"
}
resource "aws_db_instance" "main" {
identifier = "app-db"
engine = "postgres"
instance_class = "db.t4g.medium"
allocated_storage = 50
username = "app_admin"
password_wo = ephemeral.aws_secretsmanager_secret_version.db.secret_string
password_wo_version = 1
}Terraform can't detect a change in a write-only value, so increase password_wo_version when you rotate the password.
Check that your provider version supports write-only arguments for the resource you are using.
Catch secrets before they land in Git
Add Gitleaks as a pre-commit hook and in CI:
gitleaks detect --source . --verbosegitleaks detect --source . --verboseIf a secret reaches Git, rotate it. Deleting the commit is not enough.
2. Protect the State File
Treat your state file like a password vault, because it may contain the same data.
Never keep state on a laptop or in Git. Use a remote backend with encryption and tight access. Here is an S3 backend:
terraform {
backend "s3" {
bucket = "acme-terraform-state-prod"
key = "network/terraform.tfstate"
region = "ap-south-1"
encrypt = true
kms_key_id = "alias/terraform-state"
use_lockfile = true
}
}terraform {
backend "s3" {
bucket = "acme-terraform-state-prod"
key = "network/terraform.tfstate"
region = "ap-south-1"
encrypt = true
kms_key_id = "alias/terraform-state"
use_lockfile = true
}
}The use_lockfile option, available since Terraform 1.10, locks state with an S3 lock file, so two applies can't run at once. Older setups use a DynamoDB table.
The bucket needs protection too:
- Block all public access.
- Enable versioning, so you can recover from a corrupted state.
- Enforce TLS with a bucket policy.
- Restrict access to the CI role and a small number of administrators.
- Log access with CloudTrail data events.
- Use a separate state file for each environment, ideally in separate accounts.
Splitting state also limits the damage. A leak of the development state should not expose production.
OpenTofu, the open-source fork of Terraform, can also encrypt state itself on the client side. That is worth a look if you need encryption that doesn't depend on your storage backend.
Plan files need the same care. A saved plan can contain sensitive values, so don't publish it as a public CI artifact.
3. Pin Your Providers and Modules
Providers and modules are code from other people that run with your credentials.
Always set version constraints:
terraform {
required_version = ">= 1.11.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}terraform {
required_version = ">= 1.11.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}Commit .terraform.lock.hcl. It records the exact provider versions and their checksums, so a changed package fails the install. If your team uses several operating systems, add the hashes for each one:
terraform providers lock -platform=linux_amd64 -platform=darwin_arm64terraform providers lock -platform=linux_amd64 -platform=darwin_arm64For modules, a Git tag can be moved. Pin to a full commit SHA, especially for modules you don't own:
module "vpc" {
source = "git::https://github.com/example-org/terraform-aws-vpc.git?ref=<full-commit-sha>"
}module "vpc" {
source = "git::https://github.com/example-org/terraform-aws-vpc.git?ref=<full-commit-sha>"
}Before you use a public module, read what it creates. Look closely for local-exec provisioners, external data sources and IAM policies. Those are the places where a module can do more than you expect.
4. Write Secure Resources by Default
Most insecure infrastructure comes from defaults and quick fixes. Here are the ones to watch.
Network
This rule opens SSH to the whole internet:
resource "aws_vpc_security_group_ingress_rule" "ssh" {
security_group_id = aws_security_group.app.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 22
to_port = 22
ip_protocol = "tcp"
}resource "aws_vpc_security_group_ingress_rule" "ssh" {
security_group_id = aws_security_group.app.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 22
to_port = 22
ip_protocol = "tcp"
}Limit it to a known range, or remove SSH and use a service like AWS Systems Manager Session Manager:
resource "aws_vpc_security_group_ingress_rule" "https_from_lb" {
security_group_id = aws_security_group.app.id
referenced_security_group_id = aws_security_group.lb.id
from_port = 443
to_port = 443
ip_protocol = "tcp"
}resource "aws_vpc_security_group_ingress_rule" "https_from_lb" {
security_group_id = aws_security_group.app.id
referenced_security_group_id = aws_security_group.lb.id
from_port = 443
to_port = 443
ip_protocol = "tcp"
}Storage
A secure S3 bucket takes more than one resource:
resource "aws_s3_bucket" "logs" {
bucket = "acme-app-logs"
}
resource "aws_s3_bucket_public_access_block" "logs" {
bucket = aws_s3_bucket.logs.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "logs" {
bucket = aws_s3_bucket.logs.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
}
}
}resource "aws_s3_bucket" "logs" {
bucket = "acme-app-logs"
}
resource "aws_s3_bucket_public_access_block" "logs" {
bucket = aws_s3_bucket.logs.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "logs" {
bucket = aws_s3_bucket.logs.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
}
}
}IAM
Never use wildcards because something failed with an access error:
data "aws_iam_policy_document" "app" {
statement {
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.logs.arn}/*"]
}
}data "aws_iam_policy_document" "app" {
statement {
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.logs.arn}/*"]
}
}Name the exact actions and resources. This is the lesson of the Capital One breach: the wider the role, the larger the blast radius of any other bug.
Wrap good defaults in modules
Don't ask every team to remember all of this. Build an internal secure-bucket or secure-database module with encryption and logging already on, and make it the normal way to create those resources.
Protect critical resources
For resources that must not be deleted by accident, add a lifecycle rule:
lifecycle {
prevent_destroy = true
}lifecycle {
prevent_destroy = true
}5. Scan Your Terraform Code
Static scanners read your code and flag insecure settings before anything is deployed.
- Trivy: scans Terraform for misconfigurations and secrets. It now includes the checks from tfsec, which was folded into Trivy.
- Checkov: has a large set of policies, can scan plan files, and understands relationships between resources.
- TFLint: catches errors and provider-specific problems that
terraform validatemisses, such as invalid instance types.
Running them is simple:
terraform fmt -check -recursive
terraform validate
tflint --init && tflint
trivy config --severity HIGH,CRITICAL --exit-code 1 .
checkov -d . --quietterraform fmt -check -recursive
terraform validate
tflint --init && tflint
trivy config --severity HIGH,CRITICAL --exit-code 1 .
checkov -d . --quietSometimes a finding is a deliberate choice, such as a public bucket for a website. Add an inline skip with the reason, so the decision can be reviewed later:
resource "aws_s3_bucket" "website" {
#checkov:skip=CKV_AWS_18:Public website bucket, access logging not required
bucket = "acme-public-website"
}resource "aws_s3_bucket" "website" {
#checkov:skip=CKV_AWS_18:Public website bucket, access logging not required
bucket = "acme-public-website"
}Run these locally with pre-commit for fast feedback, and in CI so they can't be skipped.
6. Scan the Plan With Policy as Code
Scanning .tf files has a limit: values from variables and modules may not be known yet.
The plan knows exactly what will happen. Export it as JSON and test that:
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
checkov -f tfplan.json
conftest test tfplan.jsonterraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
checkov -f tfplan.json
conftest test tfplan.jsonConftest uses Open Policy Agent to run your own rules. This policy blocks any new security group rule that opens SSH to the internet:
package main
import rego.v1
deny contains msg if {
rule := input.resource_changes[_]
rule.type == "aws_vpc_security_group_ingress_rule"
rule.change.after.cidr_ipv4 == "0.0.0.0/0"
rule.change.after.from_port <= 22
rule.change.after.to_port >= 22
msg := sprintf("%s opens SSH to the internet", [rule.address])
}package main
import rego.v1
deny contains msg if {
rule := input.resource_changes[_]
rule.type == "aws_vpc_security_group_ingress_rule"
rule.change.after.cidr_ipv4 == "0.0.0.0/0"
rule.change.after.from_port <= 22
rule.change.after.to_port >= 22
msg := sprintf("%s opens SSH to the internet", [rule.address])
}Policy as code is where you write your company's rules: allowed regions, required tags, approved instance types, and no public databases.
HCP Terraform and Terraform Enterprise offer the same idea through Sentinel and OPA policies.
Treat tfplan.json as sensitive, because it can contain secret values.
7. Secure the Pipeline
Your CI pipeline holds the keys to your cloud. It deserves the most attention.
Use OIDC, not access keys
With OIDC, your pipeline gets short-lived credentials for each run, so no cloud keys are stored in CI.
The AWS role trusts only one repository and one environment:
data "aws_iam_policy_document" "apply_trust" {
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:acme/infrastructure:environment:production"]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
}
}data "aws_iam_policy_document" "apply_trust" {
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:acme/infrastructure:environment:production"]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
}
}That sub condition is the important part. Without it, other repositories could assume your role.
Separate plan and apply
Use two roles:
- Plan role: read-only. Runs on every pull request.
- Apply role: can change infrastructure. Only runs from the main branch, in a protected environment, after approval.
Don't run untrusted plans
terraform plan can run code. An external data source, or a malicious provider, executes during plan.
So a pull request that changes .tf files can run commands on your runner with whatever credentials are available there. This is why pull requests from forks should never get cloud credentials, and why even the plan role should stay read-only.
A complete GitHub Actions workflow
name: terraform
on:
pull_request:
paths: ["infra/**"]
push:
branches: [main]
paths: ["infra/**"]
permissions:
contents: read
id-token: write
jobs:
plan:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
defaults:
run:
working-directory: infra
steps:
- uses: actions/checkout@<sha> # pin a release
- uses: hashicorp/setup-terraform@<sha> # pin a release
- uses: aws-actions/configure-aws-credentials@<sha> # pin a release
with:
role-to-assume: ${{ vars.PLAN_ROLE_ARN }}
aws-region: ap-south-1
- run: gitleaks detect --source . --verbose
- run: trivy config --severity HIGH,CRITICAL --exit-code 1 .
- run: terraform init -input=false
- run: terraform validate
- run: terraform plan -input=false -out=tfplan
- run: terraform show -json tfplan > tfplan.json
- run: conftest test tfplan.json
apply:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment: production # requires a reviewer's approval
defaults:
run:
working-directory: infra
steps:
- uses: actions/checkout@<sha> # pin a release
- uses: hashicorp/setup-terraform@<sha> # pin a release
- uses: aws-actions/configure-aws-credentials@<sha> # pin a release
with:
role-to-assume: ${{ vars.APPLY_ROLE_ARN }}
aws-region: ap-south-1
- run: terraform init -input=false
- run: terraform plan -input=false -out=tfplan
- run: terraform apply -input=false tfplanname: terraform
on:
pull_request:
paths: ["infra/**"]
push:
branches: [main]
paths: ["infra/**"]
permissions:
contents: read
id-token: write
jobs:
plan:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
defaults:
run:
working-directory: infra
steps:
- uses: actions/checkout@<sha> # pin a release
- uses: hashicorp/setup-terraform@<sha> # pin a release
- uses: aws-actions/configure-aws-credentials@<sha> # pin a release
with:
role-to-assume: ${{ vars.PLAN_ROLE_ARN }}
aws-region: ap-south-1
- run: gitleaks detect --source . --verbose
- run: trivy config --severity HIGH,CRITICAL --exit-code 1 .
- run: terraform init -input=false
- run: terraform validate
- run: terraform plan -input=false -out=tfplan
- run: terraform show -json tfplan > tfplan.json
- run: conftest test tfplan.json
apply:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment: production # requires a reviewer's approval
defaults:
run:
working-directory: infra
steps:
- uses: actions/checkout@<sha> # pin a release
- uses: hashicorp/setup-terraform@<sha> # pin a release
- uses: aws-actions/configure-aws-credentials@<sha> # pin a release
with:
role-to-assume: ${{ vars.APPLY_ROLE_ARN }}
aws-region: ap-south-1
- run: terraform init -input=false
- run: terraform plan -input=false -out=tfplan
- run: terraform apply -input=false tfplanFill in real commit SHAs and install the scanners on your runner. The structure is what matters: scan and plan on pull requests, then apply only after approval, with a separate role.
Review the plan, not just the code
A small code change can produce a large plan. Before you approve, read what will actually happen.
Pay special attention to anything marked for destroy or replace, any IAM change, and any security group change.
8. Watch for Drift
Someone will eventually click something in the console during an incident. Your code then no longer matches reality, and a security fix might be silently undone.
Run a scheduled plan to detect this:
terraform plan -detailed-exitcode -input=falseterraform plan -detailed-exitcode -input=falseThe exit code tells you what happened: 0 means no changes, 1 means an error, and 2 means there is a difference. Alert your team on 2.
Then, enforce guardrails that Terraform can't remove. AWS Service Control Policies, for example, can deny public S3 access or restrict regions across the organization, even if a Terraform role tries to allow it.
Finally, watch what actually gets deployed with CloudTrail, AWS Config, or Prowler. Code scanning shows what you intended. Runtime checks show what is true.
The Secure Terraform Workflow, End to End
Here is how it all fits together:
1. On the developer's machine
- Pre-commit runs
terraform fmt, TFLint, Trivy and Gitleaks.
2. In the pull request
- CI scans the code, creates a plan with a read-only OIDC role, and runs Conftest and Checkov on it.
- A teammate reviews both the code and the plan.
3. At deployment
- Merging to main starts a protected job.
- A reviewer approves, and a separate OIDC role applies the change.
4. After deployment
- State stays encrypted and locked in a remote backend.
- Scheduled plans detect drift.
- Prowler and CloudTrail check what is really running.
Common Mistakes to Avoid
- Committing
terraform.tfstateor.tfvarsfiles to Git. - Assuming
sensitive = trueencrypts a value. - Giving the CI role
AdministratorAccessand long-lived keys. - Using one state file for every environment.
- Pinning modules to
mainor a movable tag. - Ignoring
.terraform.lock.hcl. - Running
terraform planwith credentials on pull requests from forks. - Approving a plan nobody read.
- Skipping scanner findings without writing down why.
- Fixing production in the console and not updating the code.
A Quick Terraform Security Checklist
- No secrets in
.tf,.tfvarsor Git, with Gitleaks in CI. - Secrets managed by the cloud, or passed as ephemeral values.
- Remote state, encrypted, locked, versioned and access-restricted.
- State separated by environment.
- Terraform, provider and module versions pinned, lock file committed.
- Public access, encryption and logging handled by default in internal modules.
- No wildcard IAM policies.
- Trivy or Checkov and TFLint running in pre-commit and CI.
- Plan tested with Conftest or Sentinel.
- CI uses OIDC with separate plan and apply roles.
- Production apply needs human approval.
- Drift detection scheduled, organization guardrails in place.
Final Thoughts
Terraform doesn't make your infrastructure secure or insecure. It makes your decisions repeatable.
That is why it is worth getting right. A mistake in a Terraform module spreads to every environment that uses it, but so does a good default.
You don't need to build all of this at once. Move your state to an encrypted backend, add Trivy and Gitleaks to your pipeline, and replace stored cloud keys with OIDC. Those three changes remove most of the serious risk.
If you only do one thing this week, check who can read your Terraform state. It may be the most sensitive file your team owns.
About me
I'm Vignesh Kumar, a security researcher working across application security, DevSecOps, cloud security, and vulnerability research.
I share practical findings from security assessments and responsible disclosures to help improve the security of real-world systems.
The examples in this article are intended for securing systems you own or administer.