Quick answer: Securing Terraform comes down to six habits: lock down who can run it and who can read the state file, keep secrets out of .tf and .tfvars files, encrypt and lock your remote state, give Terraform the minimum IAM permissions it needs, scan your code for misconfigurations before apply, and keep an audit trail of every change. Terraform is powerful precisely because it can create and destroy anything — so the same care you give a production database applies to your Terraform setup.
This guide walks through each of those areas with concrete configuration you can copy into your own projects. You will learn how to protect state, inject secrets safely, scope credentials, add automated security scanning, and avoid the mistakes that regularly show up in real security incidents.
Why Terraform security is different from application security
An application bug usually breaks one feature. A Terraform mistake can open a database to the internet, delete a production VPC, or leak every password in your state file. Three properties make Terraform special:
- It holds the keys to everything. The credentials Terraform uses typically have permission to create, modify and destroy infrastructure across whole accounts.
- State is a treasure map. The
terraform.tfstatefile records every resource attribute, including database passwords, private keys and connection strings — in plain text. - Changes are fast and repeatable. That is wonderful for delivery and dangerous if a bad change reaches
applywithout review.
If you are still getting comfortable with the basics, start with How to Write Terraform Code: A Beginner’s Guide and return here once you have run your first apply.
1. Protect the state file
Local state on a laptop is the single most common security gap in small teams. Move state to a remote backend that supports encryption, versioning and locking. On AWS that means an S3 bucket with server-side encryption and public access blocked; Terraform 1.10 and later can lock directly in S3 with use_lockfile, so a separate DynamoDB table is no longer required.
terraform {
backend "s3" {
bucket = "tkh-terraform-state-prod"
key = "network/terraform.tfstate"
region = "ap-south-1"
encrypt = true
kms_key_id = "arn:aws:kms:ap-south-1:123456789012:key/0f1e2d3c-..."
use_lockfile = true
}
}
Then restrict the bucket itself. Only the CI role and a small group of engineers should be able to read it, because reading state is equivalent to reading every secret inside it. Enable bucket versioning so an accidental corruption or deletion can be rolled back, and turn on access logging so every read is recorded.
2. Keep secrets out of code — and out of state where possible
Never write a password, access key or API token as a literal in HCL. Pass secrets in from a secrets manager at runtime and mark variables as sensitive so they are redacted from plan output and logs:
variable "db_password" {
type = string
sensitive = true
}
data "aws_secretsmanager_secret_version" "db" {
secret_id = "prod/orders/db"
}
resource "aws_db_instance" "orders" {
identifier = "orders-prod"
engine = "postgres"
instance_class = "db.t3.medium"
allocated_storage = 50
username = "app"
password = jsondecode(data.aws_secretsmanager_secret_version.db.secret_string)["password"]
storage_encrypted = true
skip_final_snapshot = false
}
Remember that sensitive = true hides values in the terminal but not in the state file. For the strongest posture, keep the secret away from Terraform entirely — for example manage_master_user_password = true on RDS lets AWS generate and rotate the password inside Secrets Manager, and Terraform 1.10+ offers ephemeral values and write-only arguments that read a secret during apply without persisting it to state. For passing environment-specific values safely, see Using Variables and Expressions in Terraform.
3. Apply least privilege to Terraform’s own credentials
The identity that runs terraform apply should have exactly the permissions the configuration needs and nothing more. In practice:
- Use short-lived credentials: authenticate CI with OIDC and assume an IAM role; never store long-lived access keys as CI secrets.
- Give each environment its own role, so the dev pipeline cannot touch production.
- Split large configurations into smaller root modules (network, data, compute) with separately scoped roles.
- Give humans read-only plan access by default and reserve apply rights for the pipeline or a break-glass role.
4. Scan code before it reaches apply
Static analysis catches the misconfigurations that reviewers miss: public S3 buckets, security groups open on 0.0.0.0/0, unencrypted volumes, missing logging. Add at least one scanner to your pipeline and make it blocking; running trivy config . or checkov -d . locally gives a prioritised list of findings with fixes.
| Tool | What it does | Typical use |
|---|---|---|
| terraform validate / fmt | Syntax and type checks, consistent formatting | Pre-commit hook and first CI step |
| tflint | Provider-aware linting (invalid instance types, deprecated syntax) | CI, fast feedback |
| Trivy / tfsec | Security misconfiguration scanning against CIS and vendor benchmarks | Blocking CI step |
| Checkov | Policy-as-code checks across Terraform, Kubernetes and more | CI plus SBOM/compliance reporting |
| OPA / Sentinel | Custom organisational policies evaluated against the plan | Enforcing “no public IPs in prod” style rules |
A minimal CI job combining these steps looks like this:
# .github/workflows/terraform-security.yml
name: terraform-security
on: [pull_request]
jobs:
scan:
runs-on: ubuntu-latest
permissions:
id-token: write # OIDC, no long-lived keys
contents: read
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform fmt -check -recursive
- run: terraform init -backend=false && terraform validate
- uses: aquasecurity/trivy-action@master
with:
scan-type: config
exit-code: "1"
severity: HIGH,CRITICAL
5. Keep an audit trail and secure defaults
You should be able to answer “who changed this and why?” for every resource. Combine three sources: Git history (every change is a reviewed pull request), pipeline logs (every plan and apply with the plan output stored as an artifact), and cloud-side audit logs such as AWS CloudTrail, Azure Activity Log or GCP Cloud Audit Logs. Alert on changes that happen outside Terraform — drift is often the first sign of a shortcut or an intruder, and a scheduled terraform plan that flags any non-empty result is a cheap way to detect it.
Finally, make secure defaults the easy path: write reusable modules whose security groups deny all inbound traffic unless explicitly opened, whose storage is encrypted by default and whose databases are never publicly accessible, and use default_tags so every resource is traceable to an owner.
Seven Terraform security mistakes to avoid
- Committing
terraform.tfstateor.tfvarswith secrets to Git. Add both to.gitignoreand scan history if you ever did. - Hard-coding
access_keyandsecret_keyin the provider block. Use environment variables, profiles or OIDC roles. - An unencrypted, publicly readable state bucket. Encrypt, block public access, version and log it.
- One admin role for every environment. Scope roles per environment and per pipeline.
- Skipping
planreview. Require a human to approve the plan before apply in production. - Ignoring provider and module upgrades. Security fixes ship in new versions; pin, review and upgrade deliberately.
- Trusting unknown registry modules blindly. Read the source, pin to a specific version, and prefer verified publishers.
Frequently asked questions
Does sensitive = true encrypt the value?
No. It only prevents the value from being printed in plan and apply output. The value is still stored in plain text in the state file, which is why remote state encryption and access control are essential.
Is HashiCorp Vault required to use Terraform securely?
No. Vault is excellent, but AWS Secrets Manager, Azure Key Vault, GCP Secret Manager or your CI system’s secret store all work. What matters is that secrets are fetched at runtime rather than committed to code.
Should developers be able to run terraform apply locally?
For sandboxes, yes. For shared and production environments, apply should run only from the pipeline with reviewed plans, so there is a single auditable path to change.
Key takeaways
- Treat state as a secret: remote, encrypted, locked, versioned and access-controlled.
- Keep credentials and passwords out of code; use secret managers,
sensitivevariables and ephemeral values. - Run Terraform with least-privilege, short-lived credentials scoped per environment.
- Scan every pull request with a security linter and require plan review before production apply.
- Log everything and detect drift so unauthorised changes surface quickly.
Want to build secure, production-grade pipelines rather than just read about them? Our DevOps course covers Terraform security, CI/CD and cloud IAM with hands-on projects, mentor support and placement assistance. For walkthroughs and demos, subscribe to our YouTube channel.


