Terraform is the blueprint for your entire cloud, which means a mistake in Terraform is a mistake replicated across every environment it provisions. A hardcoded secret isn't one leaked credential — it's one committed to version control and copied to every workspace that uses the module. An over-permissive security group isn't one open port — it's a pattern that propagates to staging, production, and disaster recovery alike. That scale is Terraform's superpower and its risk. This guide covers the practices that keep your infrastructure-as-code from becoming your largest attack surface, organized from the most common mistakes to the structural ones.
Never Hardcode Secrets in HCL
The most frequent Terraform security failure is a secret written into a .tf file. It ends up in version control history forever, even if you delete it later. Reference secrets from a manager instead:
data "aws_secretsmanager_secret_version" "db" {
secret_id = "prod/db/password"
}
resource "aws_db_instance" "main" {
username = "app"
password = data.aws_secretsmanager_secret_version.db.secret_string
# never: password = "hunter2"
}
And remember: even referenced secrets land in state (covered next). Scan every commit for secrets so a hardcoded credential is caught before it merges, not after it's in the git history of every clone.
Treat State as Highly Sensitive
Terraform state stores the full resolved configuration of your infrastructure — including secrets, in plaintext, by default. A leaked terraform.tfstate is a leaked credential dump. Protect it:
- Use a remote backend (S3 with encryption, Azure Storage, GCS) — never commit state to git.
- Encrypt the backend at rest with a customer-managed key.
- Restrict access to the backend bucket tightly; state read access is production credential access.
- Enable state locking (DynamoDB for S3 backends) to prevent concurrent-apply corruption.
terraform {
backend "s3" {
bucket = "acme-tf-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
encrypt = true
kms_key_id = "arn:aws:kms:us-east-1:111122223333:key/abc"
dynamodb_table = "acme-tf-locks"
}
}
Pin Providers and Modules
Unpinned versions are a supply chain risk. A provider or module that floats to "latest" can pull in a new version with a behavior change — or, in the worst case, a compromised release. Pin versions and use a lock file:
terraform {
required_version = ">= 1.9.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.60"
}
}
}
Commit .terraform.lock.hcl so every apply uses verified provider checksums. Prefer modules from sources you control or trust, and review third-party modules before adopting them — a module runs with your credentials.
Enforce Least Privilege in the Resources You Define
Terraform makes it trivial to write an over-broad IAM policy once and deploy it everywhere. Scope resources deliberately: explicit security-group rules instead of wide CIDR blocks, IAM policies with named actions and resource ARNs instead of wildcards, and encryption enabled on every storage and database resource. Because these patterns propagate, a single reviewed, least-privilege module is worth more than a hundred hand-corrected resources.
Scan HCL for Misconfigurations in CI
Human review misses things; a scanner doesn't. Run a misconfiguration scanner against your HCL on every pull request. It encodes the rules above — no public buckets, no wildcard IAM, encryption required, no unpinned providers — and fails the build when they're violated. This is the enforcement layer that makes the other practices stick, and it's the core of infrastructure-as-code scanning. The historical tools in this space — tfsec, checkov — have largely consolidated into engines like Trivy, which is what Safeguard runs under the hood.
# CI gate example
- name: Terraform security scan
run: safeguard scan iac ./terraform --fail-on high
Gate the Pipeline, Don't Just Warn
A scan that only prints warnings gets ignored under deadline pressure. Configure the CLI to fail on high-severity findings so a dangerous plan can't merge. Pair that with a scan of the application dependencies you're deploying via software composition analysis, because the infrastructure being safe doesn't help if the code running on it ships a critical CVE.
Terraform Security Checklist
| Practice | Control |
|---|---|
| Secrets | Referenced from a manager, never in HCL; secret-scan every commit |
| State | Remote, encrypted, access-restricted, locked |
| Versions | Providers/modules pinned, lock file committed |
| Least privilege | Named actions + resource ARNs, encryption everywhere |
| Scanning | Misconfiguration scan on every PR |
| Enforcement | Build fails on high-severity findings |
How Safeguard Helps
Safeguard secures Terraform where it's cheapest — in the pull request. Its IaC scanning runs Trivy's misconfiguration engine over your HCL to catch public buckets, wildcard IAM, missing encryption, and unpinned or risky providers, mapping each finding to the exact line so a developer fixes it before merge. Secret detection flags hardcoded credentials before they reach git history, and because everything runs through the pipeline-native CLI, you can gate merges on severity rather than relying on advisory warnings. Safeguard also runs software composition analysis on the application dependencies deploying onto that infrastructure, so the whole artifact — infrastructure and code — is covered in one pass. Teams currently using a developer-security tool for this can compare approaches in the Safeguard vs Snyk comparison.
Terraform's leverage cuts both ways: a good pattern scales, and so does a bad one. Keep secrets out of HCL, treat state as a credential store, pin your supply chain, and scan every plan before it merges — and Terraform stays the safe blueprint it's meant to be.
Ready to harden your Terraform in CI? Create a free Safeguard account or read the documentation to add IaC scanning to your pipeline.