The Big Three
- Never put secrets in code — use variables, environment variables, or Vault
- Encrypt and lock state — state files contain every secret in plaintext
- Least-privilege IAM — Terraform should only have the permissions it needs
1. Never Hardcode Secrets
The Problem
# NEVER DO THIS
provider "aws" {
access_key = "AKIAIOSFODNN7EXAMPLE"
secret_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
}
resource "aws_db_instance" "main" {
password = "SuperSecret123!" # Stored in state AND version control
}
This puts credentials in Git history forever.
Solution: Environment Variables
export AWS_ACCESS_KEY_ID="AKIAIOSFODNN7EXAMPLE"
export AWS_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
# No credentials in code — provider reads from environment
provider "aws" {
region = "us-east-1"
}
Solution: Sensitive Variables
variable "db_password" {
description = "Database master password"
type = string
sensitive = true # Won't show in plan/apply output
}
resource "aws_db_instance" "main" {
password = var.db_password
}
Pass via environment variable:
export TF_VAR_db_password="SuperSecret123!"
terraform apply
Solution: HashiCorp Vault
data "vault_generic_secret" "db" {
path = "secret/database/prod"
}
resource "aws_db_instance" "main" {
password = data.vault_generic_secret.db.data["password"]
}
Solution: AWS Secrets Manager
data "aws_secretsmanager_secret_version" "db" {
secret_id = "prod/database/password"
}
resource "aws_db_instance" "main" {
password = jsondecode(data.aws_secretsmanager_secret_version.db.secret_string)["password"]
}
Solution: Generate Random Passwords
resource "random_password" "db" {
length = 32
special = true
}
resource "aws_db_instance" "main" {
password = random_password.db.result
}
# Store in Secrets Manager for application access
resource "aws_secretsmanager_secret_version" "db" {
secret_id = aws_secretsmanager_secret.db.id
secret_string = random_password.db.result
}
2. Secure the State File
Terraform state contains every attribute of every resource — including passwords, private keys, and API tokens. In plaintext.
Encrypt State at Rest
S3 Backend with encryption:
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
encrypt = true # SSE-S3 encryption
kms_key_id = "arn:aws:kms:..." # Optional: SSE-KMS
dynamodb_table = "terraform-locks" # State locking
}
}
Restrict State Access
# S3 bucket policy — only CI/CD role can access
resource "aws_s3_bucket_policy" "state" {
bucket = aws_s3_bucket.terraform_state.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Deny"
Principal = "*"
Action = "s3:*"
Resource = [
aws_s3_bucket.terraform_state.arn,
"${aws_s3_bucket.terraform_state.arn}/*"
]
Condition = {
StringNotEquals = {
"aws:PrincipalArn" = var.terraform_role_arn
}
}
}]
})
}
Block Public Access
resource "aws_s3_bucket_public_access_block" "state" {
bucket = aws_s3_bucket.terraform_state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
Enable Versioning (State Recovery)
resource "aws_s3_bucket_versioning" "state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
Never Commit State to Git
# .gitignore
*.tfstate
*.tfstate.*
*.tfplan
.terraform/
3. Least-Privilege IAM
Don’t Use Admin Credentials
# Bad — admin access
resource "aws_iam_policy" "terraform" {
policy = jsonencode({
Statement = [{
Effect = "Allow"
Action = "*"
Resource = "*"
}]
})
}
# Good — specific permissions
resource "aws_iam_policy" "terraform" {
policy = jsonencode({
Statement = [
{
Effect = "Allow"
Action = [
"ec2:*",
"s3:*",
"rds:*"
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"iam:CreateRole",
"iam:AttachRolePolicy"
]
Resource = "arn:aws:iam::*:role/app-*" # Only app- prefixed roles
}
]
})
}
Use Assume Role in CI/CD
provider "aws" {
region = "us-east-1"
assume_role {
role_arn = "arn:aws:iam::123456789012:role/TerraformDeployRole"
session_name = "terraform-ci"
}
}
Use OIDC for CI/CD (No Long-Lived Keys)
GitLab CI with OIDC:
plan:
id_tokens:
GITLAB_OIDC_TOKEN:
aud: https://gitlab.com
script:
- |
export $(aws sts assume-role-with-web-identity \
--role-arn $ROLE_ARN \
--role-session-name "gitlab-${CI_PIPELINE_ID}" \
--web-identity-token $GITLAB_OIDC_TOKEN \
--query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' \
--output text | xargs -n3 printf 'AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s\n')
- terraform plan
4. Security Scanning
tfsec (Now Part of Trivy)
# Install
brew install tfsec
# Scan
tfsec .
Result: CRITICAL - Security group rule allows ingress from public internet
Resource: aws_security_group_rule.allow_all
Location: main.tf:15
Result: HIGH - S3 bucket does not have encryption enabled
Resource: aws_s3_bucket.data
Location: storage.tf:1
Checkov
pip install checkov
checkov -d .
CI/CD Integration
security-scan:
stage: validate
script:
- tfsec . --format json > tfsec-report.json
- checkov -d . -o json > checkov-report.json
artifacts:
reports:
sast: tfsec-report.json
5. .gitignore for Terraform
Every Terraform project needs this:
# State files
*.tfstate
*.tfstate.*
# Plan files
*.tfplan
# Provider plugins
.terraform/
# Sensitive variable files
*.auto.tfvars
secret.tfvars
# Override files
override.tf
override.tf.json
# CLI config
.terraformrc
terraform.rc
# Crash logs
crash.log
crash.*.log
Security Checklist
| Check | Status |
|---|---|
No hardcoded credentials in .tf files | ☐ |
sensitive = true on secret variables | ☐ |
| State encrypted at rest (S3 SSE / GCS encryption) | ☐ |
| State bucket has no public access | ☐ |
| State bucket has versioning enabled | ☐ |
| DynamoDB/GCS state locking enabled | ☐ |
.terraform/ and *.tfstate in .gitignore | ☐ |
| CI/CD uses OIDC or assume-role (not long-lived keys) | ☐ |
| CI/CD variables marked as protected + masked | ☐ |
| tfsec/checkov runs in CI pipeline | ☐ |
| Provider versions pinned | ☐ |
.terraform.lock.hcl committed | ☐ |
Hands-On Courses
Learn by doing with interactive courses on CopyPasteLearn:
Conclusion
Never hardcode secrets — use environment variables, Vault, or Secrets Manager. Encrypt state with S3 SSE-KMS, block public access, enable versioning and locking. Use least-privilege IAM with assume-role and OIDC (no long-lived keys). Run tfsec and checkov in CI. These practices prevent the most common Terraform security mistakes.



