/Terraform Security Best Practices
Concept
Hard

Terraform Security Best Practices

11 min read·securitysensitive-variablesstate-encryptioniam-least-privilegeoidccheckovtfsecsentinelsecrets-managementterraform-associate

Terraform security spans five layers: credential handling, sensitive data in config and state, IAM least privilege, static analysis scanning, and policy-as-code enforcement. The exam tests sensitive variables, state encryption, OIDC authentication, static analysis tools (Checkov, tfsec, tflint), Sentinel policy enforcement levels, and the never-hardcode-secrets rule.


1. The Five Security Layers

Terraform security is a layered concern — a breach at any layer can expose secrets or allow unauthorized infrastructure changes:

Rendering diagram…

2. The Cardinal Rule: Never Hardcode Secrets

Secrets in .tf files end up in version control. Secrets in terraform.tfvars end up in state. Both are permanent exposure risks:

hcl
1# NEVER do this:
2resource "aws_db_instance" "main" {
3  username = "admin"
4  password = "MyS3cretPassword!"   # committed to Git, stored in state
5}
6
7# NEVER do this in variables.tf:
8variable "db_password" {
9  default = "MyS3cretPassword!"    # still in Git
10}
11
12# NEVER put secrets in terraform.tfvars:
13# db_password = "MyS3cretPassword!"   # state stores all var values

What ends up in state:

  • Every resource attribute including passwords, private keys, tokens
  • Output values (even sensitive ones — they're redacted from display, not from the file)
  • Variable values (if they flow into resource attributes)

3. sensitive = true — Display Redaction

Mark variables and outputs as sensitive to prevent values from appearing in plan/apply terminal output:

hcl
1# Variable declaration
2variable "db_password" {
3  description = "RDS master password"
4  type        = string
5  sensitive   = true   # redacted in plan output
6}
7
8variable "api_key" {
9  type      = string
10  sensitive = true
11}
12
13# Output — must be sensitive if it references a sensitive variable
14output "db_connection_string" {
15  value     = "postgres://admin:${var.db_password}@${aws_db_instance.main.endpoint}/app"
16  sensitive = true   # required when referencing sensitive vars
17}

What sensitive = true does and does NOT do:

BehaviorDoes It Apply?
Redacts value from terraform plan outputYES
Redacts value from terraform apply outputYES
Redacts from terraform output (shows "(sensitive value)")YES
Encrypts value in state fileNO — still plaintext
Prevents value from being passed to child modulesNO
Prevents value from being used in resource argumentsNO
bash
1# Extract sensitive output (bypasses display protection — intended for scripts)
2terraform output -raw db_password
3terraform output -json | jq -r '.db_password.value'

4. Secrets Management Patterns

Pattern 1: AWS Secrets Manager (retrieve at plan/apply time)

hcl
1# Read secret at runtime — never stored in .tf files
2data "aws_secretsmanager_secret_version" "db_password" {
3  secret_id = "prod/rds/master-password"
4}
5
6resource "aws_db_instance" "main" {
7  identifier     = "prod-db"
8  engine         = "postgres"
9  instance_class = "db.t3.medium"
10  username       = "admin"
11  password       = data.aws_secretsmanager_secret_version.db_password.secret_string
12  # Value read from Secrets Manager at apply time
13  # Still ends up in state — encrypt your state
14}

Pattern 2: AWS SSM Parameter Store

hcl
1# Read SecureString parameter
2data "aws_ssm_parameter" "db_password" {
3  name            = "/prod/db/password"
4  with_decryption = true
5}
6
7resource "aws_db_instance" "main" {
8  password = data.aws_ssm_parameter.db_password.value
9}

Pattern 3: TF_VAR_ environment variables (for CI/CD)

bash
1# Inject secrets via environment variables — never written to disk
2export TF_VAR_db_password="$(aws secretsmanager get-secret-value   --secret-id prod/db/password --query SecretString --output text)"
3
4terraform apply

Pattern 4: SOPS (Secrets OPerationS)

bash
1# Encrypt secrets file with AWS KMS
2sops --kms arn:aws:kms:us-east-1:123456789:key/abc123   --encrypt secrets.yaml > secrets.enc.yaml
3
4# Commit secrets.enc.yaml to Git (encrypted)
5# Decrypt at apply time:
6sops -d secrets.enc.yaml | terraform apply -var-file=/dev/stdin

Pattern 5: Vault Provider (HashiCorp Vault)

hcl
1provider "vault" {
2  address = "https://vault.example.com"
3}
4
5data "vault_generic_secret" "db_credentials" {
6  path = "secret/prod/database"
7}
8
9resource "aws_db_instance" "main" {
10  username = data.vault_generic_secret.db_credentials.data["username"]
11  password = data.vault_generic_secret.db_credentials.data["password"]
12}

5. State Security

State files contain every resource attribute — including all secrets. Treat state as highly sensitive:

hcl
1# Secure S3 backend configuration
2terraform {
3  backend "s3" {
4    bucket         = "my-company-terraform-state"
5    key            = "prod/app/terraform.tfstate"
6    region         = "us-east-1"
7    encrypt        = true              # SSE-S3 encryption at rest
8    kms_key_id     = "arn:aws:kms:us-east-1:123456789012:key/abc123"  # SSE-KMS
9    dynamodb_table = "terraform-locks" # state locking
10  }
11}

S3 state bucket hardening:

hcl
1# Enforce encryption on all objects
2resource "aws_s3_bucket_server_side_encryption_configuration" "state" {
3  bucket = aws_s3_bucket.terraform_state.id
4  rule {
5    apply_server_side_encryption_by_default {
6      sse_algorithm     = "aws:kms"
7      kms_master_key_id = aws_kms_key.terraform_state.arn
8    }
9    bucket_key_enabled = true   # reduce KMS request costs
10  }
11}
12
13# Block all public access
14resource "aws_s3_bucket_public_access_block" "state" {
15  bucket                  = aws_s3_bucket.terraform_state.id
16  block_public_acls       = true
17  block_public_policy     = true
18  ignore_public_acls      = true
19  restrict_public_buckets = true
20}
21
22# Enable versioning for state recovery
23resource "aws_s3_bucket_versioning" "state" {
24  bucket = aws_s3_bucket.terraform_state.id
25  versioning_configuration { status = "Enabled" }
26}
27
28# Enforce SSL-only access
29resource "aws_s3_bucket_policy" "state" {
30  bucket = aws_s3_bucket.terraform_state.id
31  policy = jsonencode({
32    Statement = [{
33      Sid       = "DenyNonSSL"
34      Effect    = "Deny"
35      Principal = "*"
36      Action    = "s3:*"
37      Resource  = [
38        "${aws_s3_bucket.terraform_state.arn}",
39        "${aws_s3_bucket.terraform_state.arn}/*"
40      ]
41      Condition = { Bool = { "aws:SecureTransport" = "false" } }
42    }]
43  })
44}

State security checklist:

ControlImplementation
Encryption at restS3 SSE-KMS with customer-managed key
Encryption in transitHTTPS enforced via bucket policy
Access controlIAM — restrict to Terraform roles only
VersioningS3 versioning enabled — recover from corruption
LockingDynamoDB prevents concurrent modification
Audit trailS3 access logging + CloudTrail
Never in Git*.tfstate and *.tfstate.backup in .gitignore

6. IAM Least Privilege for Terraform

Dedicated Terraform Role

hcl
1# Create a dedicated Terraform execution role
2resource "aws_iam_role" "terraform_execution" {
3  name = "terraform-execution-role"
4
5  assume_role_policy = jsonencode({
6    Statement = [{
7      Effect    = "Allow"
8      Principal = { AWS = "arn:aws:iam::123456789012:root" }
9      Action    = "sts:AssumeRole"
10      Condition = {
11        StringEquals = {
12          "aws:PrincipalTag/Role" = "terraform-ci"
13        }
14      }
15    }]
16  })
17}
18
19# Separate plan (read-only) from apply (write) permissions
20resource "aws_iam_policy" "terraform_plan" {
21  name = "terraform-plan-readonly"
22  policy = jsonencode({
23    Statement = [
24      { Effect = "Allow", Action = ["ec2:Describe*", "s3:GetObject", "s3:ListBucket",
25        "iam:Get*", "iam:List*"], Resource = "*" },
26      { Effect = "Allow", Action = ["s3:GetObject"],
27        Resource = "${aws_s3_bucket.terraform_state.arn}/*" }
28    ]
29  })
30}

IAM Permission Boundaries

hcl
1# Constrain what Terraform can do even with broad permissions
2resource "aws_iam_policy" "terraform_boundary" {
3  name = "terraform-permission-boundary"
4  policy = jsonencode({
5    Statement = [
6      {
7        Effect   = "Allow"
8        Action   = ["ec2:*", "s3:*", "rds:*", "iam:*"]
9        Resource = "*"
10        Condition = {
11          StringEquals = { "aws:RequestedRegion" = ["us-east-1", "us-west-2"] }
12        }
13      },
14      {
15        Effect   = "Deny"
16        Action   = ["iam:CreateUser", "iam:DeleteUser", "organizations:*"]
17        Resource = "*"
18      }
19    ]
20  })
21}

7. OIDC Authentication — No Long-Lived Keys

OIDC (OpenID Connect) lets CI/CD platforms authenticate to AWS without storing static credentials:

Rendering diagram…
hcl
1# Configure AWS to trust GitHub Actions OIDC
2resource "aws_iam_openid_connect_provider" "github" {
3  url             = "https://token.actions.githubusercontent.com"
4  client_id_list  = ["sts.amazonaws.com"]
5  thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
6}
7
8resource "aws_iam_role" "github_actions" {
9  name = "github-actions-terraform"
10
11  assume_role_policy = jsonencode({
12    Statement = [{
13      Effect    = "Allow"
14      Principal = { Federated = aws_iam_openid_connect_provider.github.arn }
15      Action    = "sts:AssumeRoleWithWebIdentity"
16      Condition = {
17        StringEquals = {
18          "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
19          "token.actions.githubusercontent.com:sub" = "repo:myorg/myrepo:ref:refs/heads/main"
20        }
21      }
22    }]
23  })
24}
yaml
1# GitHub Actions workflow using OIDC (no stored secrets)
2jobs:
3  terraform:
4    permissions:
5      id-token: write   # required for OIDC
6      contents: read
7    steps:
8      - uses: aws-actions/configure-aws-credentials@v4
9        with:
10          role-to-assume: arn:aws:iam::123456789012:role/github-actions-terraform
11          aws-region: us-east-1
12          # No access keys needed!
13      - run: terraform apply -auto-approve

8. Static Analysis Tools

Run these in CI before terraform plan to catch misconfigurations early:

Checkov — Policy Scanning

bash
1# Install
2pip install checkov
3
4# Scan current directory
5checkov -d . --framework terraform
6
7# Scan with specific checks only
8checkov -d . --check CKV_AWS_18,CKV_AWS_19
9
10# Skip specific checks (with justification)
11checkov -d . --skip-check CKV_AWS_7
12
13# Output as JUnit XML for CI
14checkov -d . --output junitxml > checkov-report.xml
hcl
1# Suppress a specific check inline (add justification comment)
2resource "aws_s3_bucket" "logs" {
3  #checkov:skip=CKV_AWS_18: "Access logging bucket — self-referential logging not needed"
4  bucket = "my-access-logs"
5}

tfsec — Lightweight Terraform Scanner

bash
1# Install
2brew install tfsec   # or: go install github.com/aquasecurity/tfsec/cmd/tfsec@latest
3
4# Scan
5tfsec .
6
7# With specific severity threshold
8tfsec . --minimum-severity HIGH
9
10# Output as JSON
11tfsec . --format json > tfsec-report.json
12
13# Ignore a specific check
14tfsec . --exclude aws-s3-enable-versioning
hcl
1# Inline suppression
2resource "aws_security_group" "bastion" {
3  #tfsec:ignore:aws-ec2-no-public-ingress-sgr
4  ingress {
5    from_port   = 22
6    to_port     = 22
7    protocol    = "tcp"
8    cidr_blocks = ["0.0.0.0/0"]   # suppressed: bastion is intentionally public
9  }
10}

tflint — Linting & Provider Rules

bash
1# Install
2brew install tflint
3
4# Initialize with AWS plugin
5tflint --init
6
7# Run linting
8tflint --recursive
9
10# .tflint.hcl configuration
11plugin "aws" {
12  enabled = true
13  version = "0.27.0"
14  source  = "github.com/terraform-linters/tflint-ruleset-aws"
15}

Trivy — Multi-Purpose Scanner

bash
1# Scan IaC files
2trivy config .
3
4# With severity filter
5trivy config --severity HIGH,CRITICAL .
6
7# Output as SARIF (for GitHub Code Scanning)
8trivy config --format sarif --output trivy.sarif .

Tool comparison:

ToolFocusBest For
CheckovCIS benchmarks, complianceBroad policy coverage, CI gates
tfsecTerraform-specific securityFast, inline suppressions
tflintSyntax, provider rules, best practicesCatching invalid resource arguments
TrivyMulti-format (IaC, containers, SBOMs)Unified scanning in one tool
TerrascanOPA policy engineCustom policy language

9. Sentinel — Policy-as-Code (Terraform Cloud/Enterprise)

Sentinel is HashiCorp's policy-as-code framework. Policies run as a gate between plan and apply:

Rendering diagram…

Three enforcement levels:

LevelBehavior on FailOverride?
advisoryWarning logged; apply proceedsN/A — not blocking
soft-mandatoryApply blockedYes — authorized users can override
hard-mandatoryApply blockedNo — cannot be overridden
python
1# sentinel/require-tags.sentinel
2import "tfplan/v2" as tfplan
3
4# All EC2 instances must have Environment and Owner tags
5required_tags = ["Environment", "Owner", "Project"]
6
7all_instances = filter tfplan.resource_changes as _, rc {
8  rc.type is "aws_instance" and rc.mode is "managed"
9}
10
11tags_present = rule {
12  all all_instances as _, instance {
13    all required_tags as tag {
14      instance.change.after.tags contains tag
15    }
16  }
17}
18
19main = rule { tags_present }
python
1# sentinel/restrict-instance-types.sentinel
2import "tfplan/v2" as tfplan
3
4allowed_instance_types = ["t3.micro", "t3.small", "t3.medium"]
5
6all_instances = filter tfplan.resource_changes as _, rc {
7  rc.type is "aws_instance"
8}
9
10valid_types = rule {
11  all all_instances as _, instance {
12    instance.change.after.instance_type in allowed_instance_types
13  }
14}
15
16main = rule { valid_types }

10. .gitignore for Terraform Security

Always include these in your .gitignore:

bash
1# .gitignore — Terraform security essentials
2
3# State files — contain all secrets in plaintext
4*.tfstate
5*.tfstate.*
6terraform.tfstate.backup
7
8# Variable files that may contain secrets
9*.tfvars
10*.tfvars.json
11# Exception: example/template files are fine to commit:
12# !terraform.tfvars.example
13
14# Terraform working directory
15.terraform/
16.terraform.lock.hcl   # DO commit this — it pins provider versions
17
18# Crash logs
19crash.log
20crash.*.log
21
22# Override files (local developer overrides — not for sharing)
23override.tf
24override.tf.json
25*_override.tf
26*_override.tf.json
27
28# CLI config
29.terraformrc
30terraform.rc

11. Security Scanning in CI/CD Pipeline

yaml
1# .github/workflows/terraform-security.yml
2name: Terraform Security
3
4on: [pull_request]
5
6jobs:
7  security-scan:
8    runs-on: ubuntu-latest
9    steps:
10      - uses: actions/checkout@v4
11
12      # 1. Lint — catch syntax and provider rule violations
13      - uses: terraform-linters/setup-tflint@v4
14      - run: tflint --init && tflint --recursive
15
16      # 2. Format check — fail if not formatted
17      - uses: hashicorp/setup-terraform@v3
18      - run: terraform fmt -check -recursive
19
20      # 3. Validate — check config is internally consistent
21      - run: terraform init -backend=false && terraform validate
22
23      # 4. Security scan — catch misconfigurations
24      - uses: bridgecrewio/checkov-action@master
25        with:
26          directory: .
27          framework: terraform
28          output_format: sarif
29          output_file_path: reports/checkov.sarif
30
31      # 5. Upload results to GitHub Security tab
32      - uses: github/codeql-action/upload-sarif@v3
33        with:
34          sarif_file: reports/checkov.sarif

12. Quick Reference

TopicKey Fact
Never hardcode secretsSecrets in .tf files go to Git; in tfvars go to state
sensitive = trueDisplay-only protection — value still in state plaintext
State encryptionS3 encrypt = true + KMS key; does not protect against IAM access
State never in GitAdd *.tfstate and *.tfstate.backup to .gitignore
IAM best practiceDedicated Terraform role; separate plan (read) vs apply (write)
OIDCNo long-lived credentials; temporary tokens per workflow run
CheckovCIS benchmark scanning; #checkov:skip=CKV_ID to suppress
tfsecTerraform-specific; #tfsec:ignore:rule-id to suppress
tflintCatches invalid arguments; requires provider plugin
Sentinel advisoryWarning only — apply continues
Sentinel soft-mandatoryBlocked — authorized override allowed
Sentinel hard-mandatoryBlocked — no override possible
.gitignore must include*.tfstate, *.tfvars, .terraform/
.gitignore should NOT exclude.terraform.lock.hcl — commit this
Secrets Manager patterndata "aws_secretsmanager_secret_version" at apply time
SSM Parameter Storedata "aws_ssm_parameter" with with_decryption = true

Practice Questions10

medium

Q1. Which tools are used to scan Terraform code for security misconfigurations? (Select all that apply — more than one answer may be correct.)


Select one answer before revealing.

hard

Q2. What is the best practice for managing database passwords in Terraform?


Select one answer before revealing.

hard

Q3. You want to prevent a production database from being accidentally destroyed via Terraform. What is the correct approach?


Select one answer before revealing.

medium

Q4. What is the `random_password` resource used for in Terraform?


Select one answer before revealing.

hard

Q5. Scenario: A Checkov scan reports `CKV_AWS_18: Ensure the S3 bucket has access logging enabled`. The S3 bucket in your Terraform configuration lacks a logging block. What is the correct remediation?


Select one answer before revealing.

hard

Q6. Scenario: You are setting up Terraform in a GitHub Actions pipeline using OIDC for AWS authentication. The workflow runs `terraform plan` successfully but `terraform apply` fails with "Access Denied" on `s3:PutObject`. What is the most likely cause?


Select one answer before revealing.

hard

Q7. Scenario: Your team uses a shared Terraform module for creating RDS databases. A developer adds `skip_final_snapshot = true` to the module's RDS resource for development speed. What is the risk?


Select one answer before revealing.

hard

Q8. Hands-On: Review this Terraform configuration. What will happen when this is applied to an AWS account? ```hcl resource "aws_security_group" "web" { name = "web-sg" vpc_id = aws_vpc.main.id ingress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } } ```


Select one answer before revealing.

hard

Q9. Hands-On: What is wrong with the following Terraform code, and what will happen when you run `terraform apply`? ```hcl resource "aws_db_instance" "main" { identifier = "production-db" engine = "mysql" instance_class = "db.t3.micro" allocated_storage = 20 username = "admin" password = "SuperSecret123!" skip_final_snapshot = true } ```


Select one answer before revealing.

hard

Q10. Scenario: Your organization wants to enforce that no Terraform configuration can create an EC2 instance with `instance_type` larger than `t3.xlarge` in development environments. Which approach best enforces this with automated checks?


Select one answer before revealing.