Skip to main content
Terraform Security Best Practices: Secrets, State, and Access Control

Terraform Security Best Practices: Secrets, State, and Access Control

Key Takeaway

Secure your Terraform workflows. Never hardcode secrets, encrypt state files, use least-privilege IAM, scan with tfsec/checkov, and implement policy-as-code with Sentinel and OPA.

Table of Contents

The Big Three

  1. Never put secrets in code — use variables, environment variables, or Vault
  2. Encrypt and lock state — state files contain every secret in plaintext
  3. 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

CheckStatus
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.

🚀

Level Up Your Terraform Skills

Hands-on courses, books, and resources from Luca Berton

Luca Berton
Written by

Luca Berton

DevOps Engineer, AWS Partner, Terraform expert, and author. Creator of Ansible Pilot, Terraform Pilot, and CopyPasteLearn.