Learn how to secure Infrastructure as Code with Terraform - covering sensitive variable handling, state file protection, provider credential security, Checkov scanning with CI/CD integration, OPA policy-as-code, and tfsec static analysis to catch misconfigurations before deployment.
Infrastructure as Code solved the provisioning problem. It did not solve the governance problem. Before IaC, misconfigurations happened at click-ops time — someone opened a security group too broadly, created a public S3 bucket, or forgot to enable encryption on a database. You needed a manual review process or an audit to catch these. With Terraform, the same misconfigurations now live in code files that are committed to version control, reviewed in pull requests, and deployed automatically. The good news: they can be caught automatically, before `terraform apply`, on every commit. That is the core promise of IaC security: the same shift-left approach that SAST applies to application code applies to infrastructure code. A security group with `0.0.0.0/0` ingress on port 22 can be caught in a pull request comment, not in a post-incident review. The three main risks in Terraform codebases: ``` 1. Misconfigurations Public S3 buckets, unrestricted security groups, unencrypted databases, missing tags, overly permissive IAM roles. Tool: Checkov, tfsec, OPA/Conftest 2. Secrets in code and state API keys, passwords, and database credentials hardcoded in .tf files or stored in the tfstate file in plain text. Tool: Sensitive variables, Vault, AWS Secrets Manager, remote state 3. Supply chain risks Unverified modules, unpinned provider versions, compromised upstream sources. Tool: Dependency lock file, private registry, version pinning ``` ---
### Never Hardcode Credentials in Provider Blocks ```hcl # BAD — credentials in code provider "aws" { access_key = "AKIAIOSFODNN7EXAMPLE" secret_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" region = "us-east-1" } # GOOD — use environment variables (Terraform picks these up automatically) # export AWS_ACCESS_KEY_ID="AKIAIOSFODNN7EXAMPLE" # export AWS_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" provider "aws" { region = "us-east-1" } # BEST — use OIDC dynamic credentials in CI/CD (no static keys at all) # Covered in the OIDC section of the CI/CD module ``` ### Mark Sensitive Variables ```hcl # variables.tf variable "db_password" { description = "Database administrator password" type = string sensitive = true # Terraform redacts this in plan/apply output } variable "db_username" { description = "Database administrator username" type = string sensitive = true } ``` Pass values at runtime, not in code: ```bash # Option 1: Environment variables (picked up automatically by Terraform) export TF_VAR_db_password="$(aws secretsmanager get-secret-value \ --secret-id prod/db/password \ --query SecretString \ --output text)" # Option 2: .tfvars file (never commit to version control) echo 'db_password = "my-secure-password"' > secret.tfvars terraform apply -var-file="secret.tfvars" # .gitignore must include: # *.tfvars # terraform.tfvars ``` ### Mark Sensitive Outputs If you derive an output from a sensitive variable, Terraform will error unless you mark the output sensitive too: ```hcl # outputs.tf — BAD: Terraform will error on apply output "db_connection_string" { value = "postgresql://${var.db_username}:${var.db_password}@${aws_db_instance.main.endpoint}/app" } # GOOD: mark as sensitive output "db_connection_string" { value = "postgresql://${var.db_username}:${var.db_password}@${aws_db_instance.main.endpoint}/app" sensitive = true } ``` ### The State File Problem Marking variables as `sensitive` only redacts them from terminal output. The values are still stored in plain text in `terraform.tfstate`. Anyone with access to that file can read every secret. ```bash # This is why you must NEVER commit tfstate to git # Check your tfstate — secrets are visible in plain text: grep "password" terraform.tfstate # Output: "password": "my-secure-password" ``` The solution is remote state with encryption: ```hcl # backend.tf — store state in S3 with encryption and locking terraform { backend "s3" { bucket = "my-terraform-state-prod" key = "app/production/terraform.tfstate" region = "us-east-1" encrypt = true # AES-256 encryption at rest dynamodb_table = "terraform-state-lock" # State locking via DynamoDB kms_key_id = "arn:aws:kms:us-east-1:123456789012:key/abc-123" } } ``` ```bash # Create the S3 bucket with versioning and encryption aws s3api create-bucket --bucket my-terraform-state-prod --region us-east-1 aws s3api put-bucket-versioning \ --bucket my-terraform-state-prod \ --versioning-configuration Status=Enabled aws s3api put-bucket-encryption \ --bucket my-terraform-state-prod \ --server-side-encryption-configuration \ '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}' # Block all public access to the state bucket aws s3api put-public-access-block \ --bucket my-terraform-state-prod \ --public-access-block-configuration \ "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true" # Create DynamoDB table for state locking aws dynamodb create-table \ --table-name terraform-state-lock \ --attribute-definitions AttributeName=LockID,AttributeType=S \ --key-schema AttributeName=LockID,KeyType=HASH \ --billing-mode PAY_PER_REQUEST ``` ### Use Ephemeral Resources for One-Time Credentials Terraform 1.10+ supports ephemeral resources — values that are used during a plan/apply but never stored in state: ```hcl # Generate a database password that is never stored in tfstate ephemeral "random_password" "db_password" { length = 24 special = false } resource "aws_secretsmanager_secret" "db_password" { name_prefix = "prod/db/password-" recovery_window_in_days = 7 } resource "aws_secretsmanager_secret_version" "db_password" { secret_id = aws_secretsmanager_secret.db_password.id secret_string_wo = jsonencode({ username = "admin", password = ephemeral.random_password.db_password.result }) } ``` ### Retrieve Secrets from Vault or Secrets Manager ```hcl # Retrieve database password from AWS Secrets Manager at apply time data "aws_secretsmanager_secret_version" "db_password" { secret_id = "prod/myapp/db-password" } resource "aws_db_instance" "main" { engine = "postgres" instance_class = "db.t3.micro" username = "admin" password = jsondecode(data.aws_secretsmanager_secret_version.db_password.secret_string)["password"] # ... } ``` ---
### Lock Providers with Checksums When you run `terraform init`, it creates `.terraform.lock.hcl` — commit this file to version control: ```hcl # .terraform.lock.hcl — generated automatically, commit this file provider "registry.terraform.io/hashicorp/aws" { version = "5.98.0" constraints = "~> 5.98.0" hashes = [ "h1:neMFK/kP1KT6cTGID+Tkkt8L7PsN9XqwrPDGXVw3WVY=", "zh:23377bd90204b6203b904f48f53edcae3294eb072d8fc18a4531c0cde531a3a1", # ... more checksums ] } ``` This ensures that subsequent `terraform init` runs use the exact same provider binary, with cryptographic verification. ### Pin Module Versions ```hcl # GOOD — specific version from registry module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "5.1.2" # Pin exact version name = "main-vpc" cidr = "10.0.0.0/16" } # BAD — no version constraint, installs latest, potentially breaking changes module "vpc" { source = "terraform-aws-modules/vpc/aws" } # BAD — using git directly without a version tag module "vpc" { source = "git::https://github.com/terraform-aws-modules/terraform-aws-vpc.git" } ``` ### Trust and Verify Module Sources Pinning versions is necessary but not sufficient. You also need to know the module source itself is trustworthy. ```hcl # Tier 1: Verified modules from the official Terraform Registry # Look for the "Verified" badge — maintained by the named partner organization module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "5.1.2" } # Tier 2: Private registry (recommended for enterprise) # Mirror approved public modules at a fixed version into your internal registry module "vpc" { source = "registry.company.internal/infra/vpc/aws" version = "5.1.2" } # Tier 3: Avoid raw Git sources in production — no checksum verification # If you must use Git, pin to a specific commit hash not a branch: module "vpc" { # BAD: branch can be force-pushed source = "git::https://github.com/user/terraform-vpc.git?ref=main" # BETTER: commit hash is immutable source = "git::https://github.com/user/terraform-vpc.git?ref=abc1234def5678" } ``` For any new public module, review `terraform plan` output before applying to production. A module that creates an S3 bucket may also create IAM roles with broader permissions than you expect. The plan is your last inspection point. ---
Checkov is an open-source static analysis tool that scans Terraform files for hundreds of known misconfigurations. It runs before `terraform apply` — catching issues at the code review stage. ### Install and Basic Scan ```bash # Install Checkov pip install checkov # Scan a Terraform directory checkov -d . # Scan only specific checks checkov -d . --check CKV_AWS_20,CKV_AWS_18 # Scan with severity filter (only HIGH and CRITICAL) checkov -d . --check-level HIGH # Skip specific checks (with documented reason) checkov -d . --skip-check CKV_AWS_144,CKV_AWS_145 # Output as SARIF for GitHub Code Scanning checkov -d . --output sarif --output-file checkov.sarif # Scan a Terraform plan file (catches resolved values) terraform plan -out=tfplan terraform show -json tfplan > tfplan.json checkov -f tfplan.json --framework terraform_plan ``` ### Understanding Checkov Output ``` Passed checks: 42, Failed checks: 3, Skipped checks: 0 Check: CKV_AWS_20: "Ensure the S3 bucket has access control list (ACL) applied and is not public" FAILED for resource: aws_s3_bucket.app_data File: /main.tf:15-25 15 | resource "aws_s3_bucket" "app_data" { 16 | bucket = "my-app-data-bucket" 17 | acl = "public-read" ← This is the problem 18 | } Guide: https://docs.bridgecrew.io/docs/s3_1-acl-read-permissions-everyone Check: CKV_AWS_18: "Ensure the S3 bucket has access logging enabled" FAILED for resource: aws_s3_bucket.app_data ... ``` ### Fix the Flagged Issues ```hcl # BEFORE — Checkov fails on public ACL and missing logging resource "aws_s3_bucket" "app_data" { bucket = "my-app-data-bucket" acl = "public-read" } # AFTER — compliant configuration resource "aws_s3_bucket" "app_data" { bucket = var.bucket_name } resource "aws_s3_bucket_acl" "app_data" { bucket = aws_s3_bucket.app_data.id acl = "private" # Private ACL } resource "aws_s3_bucket_public_access_block" "app_data" { bucket = aws_s3_bucket.app_data.id block_public_acls = true # Block public ACLs block_public_policy = true # Block public policies ignore_public_acls = true restrict_public_buckets = true } resource "aws_s3_bucket_logging" "app_data" { bucket = aws_s3_bucket.app_data.id target_bucket = aws_s3_bucket.access_logs.id # Access logging enabled target_prefix = "app-data/" } resource "aws_s3_bucket_server_side_encryption_configuration" "app_data" { bucket = aws_s3_bucket.app_data.id rule { apply_server_side_encryption_by_default { sse_algorithm = "AES256" # Encryption at rest } } } ``` ### Skip a Check with a Documented Reason When a check genuinely does not apply, suppress it with an inline comment explaining why: ```hcl resource "aws_s3_bucket" "public_assets" { bucket = "my-company-public-assets" #checkov:skip=CKV_AWS_20: This bucket intentionally hosts public static assets (CSS, JS, images) #checkov:skip=CKV_AWS_18: Access logs for public CDN bucket stored in central log bucket via CloudFront } ``` ### Checkov in GitHub Actions ```yaml # .github/workflows/iac-security.yml name: IaC Security Scan on: push: branches: [main] pull_request: branches: [main] paths: - '**/*.tf' - '**/*.tfvars' jobs: checkov-scan: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.11' - name: Install Checkov run: pip install checkov # Scan raw Terraform code - name: Run Checkov on Terraform code run: | checkov -d . \ --output sarif \ --output-file checkov-results.sarif \ --soft-fail # Don't fail build yet — we upload results first - name: Upload SARIF to GitHub Security tab uses: github/codeql-action/upload-sarif@v3 if: always() with: sarif_file: checkov-results.sarif # Hard fail on CRITICAL/HIGH findings - name: Fail build on critical findings run: | checkov -d . \ --check-level HIGH \ --compact # For deeper scanning: scan the Terraform plan - name: Setup Terraform uses: hashicorp/setup-terraform@v3 - name: Terraform Init run: terraform init -backend=false - name: Terraform Plan run: | terraform plan -out=tfplan terraform show -json tfplan > tfplan.json env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - name: Checkov Plan Scan run: | checkov -f tfplan.json \ --framework terraform_plan \ --compact ``` ---
tfsec provides near-instant feedback on Terraform code and is faster than Checkov for developer-loop use. It is still actively maintained and widely deployed. Aqua Security has integrated its engine into Trivy, so new teams starting fresh may prefer `trivy config .` for a single scanner across IaC, containers, and dependencies — the rules and output format are equivalent. Existing tfsec users have no urgent reason to migrate. ```bash # Install tfsec brew install tfsec # macOS # or docker run --rm -v "$(pwd):/src" aquasec/tfsec /src # Basic scan tfsec . # Scan with specific format tfsec . --format lovely # Default readable output tfsec . --format json # For automation tfsec . --format sarif # For GitHub Code Scanning # Fail on specific severities tfsec . --minimum-severity HIGH # Ignore specific rules tfsec . -e aws-s3-enable-bucket-logging # In-code ignore with reason resource "aws_s3_bucket" "public_assets" { bucket = "public-cdn-assets" #tfsec:ignore:aws-s3-enable-bucket-logging -- CDN access logged via CloudFront } ``` ### tfsec in GitHub Actions ```yaml - name: Run tfsec uses: aquasecurity/tfsec-action@v1 with: working_directory: . format: sarif additional_args: > --minimum-severity HIGH --soft-fail-on-missing-provider ``` ---
Checkov and tfsec catch known bad patterns. OPA (Open Policy Agent) lets you encode your organization's specific policies in code — rules like "all EC2 instances must have the `owner` and `cost-center` tags" or "production environments may only use approved instance types." ### How It Works ```bash # 1. Generate the Terraform plan as JSON terraform plan -out=tfplan terraform show -json tfplan > tfplan.json # 2. Evaluate with Conftest (OPA wrapper for CI/CD) conftest test tfplan.json --policy policies/ ``` ### Write OPA Policies in Rego (v1 syntax) ```rego # policies/tagging.rego package terraform.analysis import rego.v1 # Define required tags for all resources required_tags := {"owner", "environment", "cost-center"} # Fail if any created/updated resource is missing required tags deny contains msg if { resource := input.resource_changes[_] resource.change.actions[_] in {"create", "update"} # Calculate which required tags are missing present_tags := {tag | resource.change.after.tags[tag]} missing := required_tags - present_tags count(missing) > 0 msg := sprintf( "Resource '%v' is missing required tags: %v", [resource.address, missing] ) } ``` ```rego # policies/security.rego package terraform.analysis import rego.v1 # Deny S3 buckets without Block Public Access deny contains msg if { resource := input.resource_changes[_] resource.type == "aws_s3_bucket_public_access_block" resource.change.actions[_] in {"create", "update"} resource.change.after.block_public_acls == false msg := sprintf( "S3 bucket public access block '%v' must have block_public_acls = true", [resource.address] ) } # Deny EC2 instances with unrestricted security groups deny contains msg if { resource := input.resource_changes[_] resource.type == "aws_security_group_rule" resource.change.actions[_] in {"create", "update"} resource.change.after.type == "ingress" resource.change.after.cidr_blocks[_] == "0.0.0.0/0" resource.change.after.from_port == 22 msg := sprintf( "Security group rule '%v' allows SSH (port 22) from 0.0.0.0/0 — restrict to known CIDR ranges", [resource.address] ) } # Deny instances using unapproved types (cost control) allowed_instance_types := {"t3.micro", "t3.small", "t3.medium", "t3.large"} deny contains msg if { resource := input.resource_changes[_] resource.type == "aws_instance" resource.change.actions[_] in {"create", "update"} instance_type := resource.change.after.instance_type not instance_type in allowed_instance_types msg := sprintf( "EC2 instance '%v' uses '%v' — approved types are: %v", [resource.address, instance_type, allowed_instance_types] ) } ``` ### OPA in GitHub Actions ```yaml # .github/workflows/opa-policy-check.yml name: OPA Policy Check on: pull_request: paths: ['**/*.tf', 'policies/**'] jobs: policy-check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Terraform uses: hashicorp/setup-terraform@v3 - name: Setup Conftest run: | curl -L https://github.com/open-policy-agent/conftest/releases/download/v0.67.1/conftest_0.67.1_Linux_x86_64.tar.gz | tar xz sudo mv conftest /usr/local/bin/ - name: Terraform Init run: terraform init -backend=false - name: Generate Terraform Plan run: | terraform plan -out=tfplan terraform show -json tfplan > tfplan.json env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - name: Run OPA Policy Check run: conftest test tfplan.json --policy policies/ - name: Upload Plan as Artifact if: always() uses: actions/upload-artifact@v4 with: name: terraform-plan path: tfplan.json ``` ---
Infrastructure as Code solved the provisioning problem. It did not solve the governance problem. Before IaC, misconfigur...
Never Hardcode Credentials in Provider Blocks Mark Sensitive Variables Pass values at runtime, not in code: Mark Sensiti...
Lock Providers with Checksums When you run terraform init, it creates .terraform.lock.hcl — commit this file to version ...
Checkov is an open-source static analysis tool that scans Terraform files for hundreds of known misconfigurations. It ru...
tfsec provides near-instant feedback on Terraform code and is faster than Checkov for developer-loop use. It is still ac...
Checkov and tfsec catch known bad patterns. OPA (Open Policy Agent) lets you encode your organization's specific policie...
Combining all controls into a single pipeline: ---...
IaC security is not only about what you deploy — it is about keeping deployed infrastructure in sync with what your code...
Part 1 — Start with Intentionally Insecure Terraform Create main.tf: Part 2 — Fix Each Category of Findings Part 3 — Add...
Aligns directly with DevOps, Site Reliability (SRE), and Platform Engineering job descriptions.