Terraform Security Best Practices
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:
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:
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 valuesWhat 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:
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:
| Behavior | Does It Apply? |
|---|---|
Redacts value from terraform plan output | YES |
Redacts value from terraform apply output | YES |
Redacts from terraform output (shows "(sensitive value)") | YES |
| Encrypts value in state file | NO — still plaintext |
| Prevents value from being passed to child modules | NO |
| Prevents value from being used in resource arguments | NO |
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)
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
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)
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 applyPattern 4: SOPS (Secrets OPerationS)
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/stdinPattern 5: Vault Provider (HashiCorp Vault)
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:
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:
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:
| Control | Implementation |
|---|---|
| Encryption at rest | S3 SSE-KMS with customer-managed key |
| Encryption in transit | HTTPS enforced via bucket policy |
| Access control | IAM — restrict to Terraform roles only |
| Versioning | S3 versioning enabled — recover from corruption |
| Locking | DynamoDB prevents concurrent modification |
| Audit trail | S3 access logging + CloudTrail |
| Never in Git | *.tfstate and *.tfstate.backup in .gitignore |
6. IAM Least Privilege for Terraform
Dedicated Terraform Role
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
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:
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}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-approve8. Static Analysis Tools
Run these in CI before terraform plan to catch misconfigurations early:
Checkov — Policy Scanning
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.xml1# 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
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-versioning1# 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
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
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:
| Tool | Focus | Best For |
|---|---|---|
| Checkov | CIS benchmarks, compliance | Broad policy coverage, CI gates |
| tfsec | Terraform-specific security | Fast, inline suppressions |
| tflint | Syntax, provider rules, best practices | Catching invalid resource arguments |
| Trivy | Multi-format (IaC, containers, SBOMs) | Unified scanning in one tool |
| Terrascan | OPA policy engine | Custom 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:
Three enforcement levels:
| Level | Behavior on Fail | Override? |
|---|---|---|
advisory | Warning logged; apply proceeds | N/A — not blocking |
soft-mandatory | Apply blocked | Yes — authorized users can override |
hard-mandatory | Apply blocked | No — cannot be overridden |
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 }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:
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.rc11. Security Scanning in CI/CD Pipeline
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.sarif12. Quick Reference
| Topic | Key Fact |
|---|---|
| Never hardcode secrets | Secrets in .tf files go to Git; in tfvars go to state |
sensitive = true | Display-only protection — value still in state plaintext |
| State encryption | S3 encrypt = true + KMS key; does not protect against IAM access |
| State never in Git | Add *.tfstate and *.tfstate.backup to .gitignore |
| IAM best practice | Dedicated Terraform role; separate plan (read) vs apply (write) |
| OIDC | No long-lived credentials; temporary tokens per workflow run |
| Checkov | CIS benchmark scanning; #checkov:skip=CKV_ID to suppress |
| tfsec | Terraform-specific; #tfsec:ignore:rule-id to suppress |
| tflint | Catches invalid arguments; requires provider plugin |
| Sentinel advisory | Warning only — apply continues |
| Sentinel soft-mandatory | Blocked — authorized override allowed |
| Sentinel hard-mandatory | Blocked — no override possible |
.gitignore must include | *.tfstate, *.tfvars, .terraform/ |
.gitignore should NOT exclude | .terraform.lock.hcl — commit this |
| Secrets Manager pattern | data "aws_secretsmanager_secret_version" at apply time |
| SSM Parameter Store | data "aws_ssm_parameter" with with_decryption = true |
Practice Questions10
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.
Q2. What is the best practice for managing database passwords in Terraform?
Select one answer before revealing.
Q3. You want to prevent a production database from being accidentally destroyed via Terraform. What is the correct approach?
Select one answer before revealing.
Q4. What is the `random_password` resource used for in Terraform?
Select one answer before revealing.
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.
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.
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.
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.
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.
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.