Common Security Mistakes #

Many infrastructure security incidents aren’t caused by sophisticated attacks, but by simple mistakes made consistently — credentials committed in a hurry, S3 buckets accidentally made public, or IAM roles with more access than needed because “it’s easier that way”. Recognizing these mistakes before making them is far cheaper than fixing them after they happen.

flowchart TD
    A["Hardcoded\nCredentials"] --> B["Data Breach"]
    C["Public\nBucket"] --> B
    D["Overly Broad\nIAM"] --> B
    E["No\nEncryption"] --> B

    style A fill:#ef4444,stroke:#dc2626,color:#fff
    style C fill:#ef4444,stroke:#dc2626,color:#fff
    style D fill:#ef4444,stroke:#dc2626,color:#fff
    style E fill:#ef4444,stroke:#dc2626,color:#fff
    style B fill:#f59e0b,stroke:#d97706,color:#fff

Mistake 1 — Hardcoded Credentials in the Configuration #

# ANTI-PATTERN: Credentials directly in the configuration
provider "aws" {
  access_key = "«redacted:AKIA…»"
  secret_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
}

# Or in a variable without the sensitive flag
variable "db_password" {
  default = "MyP4ssword123"  # ✗ The default value is committed to Git
}

# CORRECT: No explicit credentials in the configuration
provider "aws" {
  region = "ap-southeast-1"
  # Credentials from the environment, instance profile, or OIDC
}

variable "db_password" {
  type      = string
  sensitive = true
  # No default — the value must be passed from outside
}

Mistake 2 — State Files in Git #

# ANTI-PATTERN: terraform.tfstate committed to Git
git add .
git commit -m "update infra"
# → terraform.tfstate gets committed along with other changes
# → State contains passwords, private keys, all resource attributes — plaintext

# Danger signs:
git log --all -- "*.tfstate"  # Check whether tfstate was ever committed
# If there's output: it's already too late, all credentials need rotation

# CORRECT: The right .gitignore
# *.tfstate in .gitignore BEFORE the first commit
# A remote backend to store state (S3, Terraform Cloud, GCS)

Mistake 3 — Accidental Public Resources #

# ANTI-PATTERN: S3 bucket without a public access block
resource "aws_s3_bucket" "data" {
  bucket = "my-company-data"
  # No access control configuration
  # The default can vary depending on the account settings
}

# ANTI-PATTERN: Overly permissive security group
resource "aws_security_group" "web" {
  ingress {
    from_port   = 0
    to_port     = 65535
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]  # ✗ All ports open to the public
  }
}

# CORRECT: Explicitly block public access for S3
resource "aws_s3_bucket_public_access_block" "data" {
  bucket = aws_s3_bucket.data.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# CORRECT: A security group with only the minimum needed ports
resource "aws_security_group" "web" {
  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]  # HTTPS — indeed needs to be public
  }

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["10.0.0.0/8"]  # SSH only from the internal network
  }

  # No ingress for other ports
}

Mistake 4 — Missed Encryption #

# ANTI-PATTERN: RDS without encryption
resource "aws_db_instance" "main" {
  identifier     = "production-db"
  engine         = "postgres"
  instance_class = "db.t3.medium"
  storage_type   = "gp2"
  allocated_storage = 20
  # storage_encrypted not set → defaults to false
}

# ANTI-PATTERN: S3 without server-side encryption
resource "aws_s3_bucket" "logs" {
  bucket = "production-logs"
  # No server_side_encryption_configuration
}

# ANTI-PATTERN: EBS volume without encryption
resource "aws_ebs_volume" "data" {
  availability_zone = "ap-southeast-1a"
  size              = 100
  # encrypted not set → defaults to false
}

# CORRECT: Encryption for all storage
resource "aws_db_instance" "main" {
  identifier        = "production-db"
  engine            = "postgres"
  instance_class    = "db.t3.medium"
  storage_encrypted = true        # ← Required
  kms_key_id        = aws_kms_key.rds.arn  # Custom KMS key (not the default)
}

resource "aws_s3_bucket_server_side_encryption_configuration" "logs" {
  bucket = aws_s3_bucket.logs.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = "aws:kms"
      kms_master_key_id = aws_kms_key.s3.arn
    }
  }
}

resource "aws_ebs_volume" "data" {
  availability_zone = "ap-southeast-1a"
  size              = 100
  encrypted         = true        # ← Required
  kms_key_id        = aws_kms_key.ebs.arn
}

Mistake 5 — Missing Logging and Monitoring #

# ANTI-PATTERN: RDS without logging
resource "aws_db_instance" "main" {
  identifier = "production-db"
  # No enabled_cloudwatch_logs_exports
  # No parameter group for audit logging
}

# ANTI-PATTERN: S3 without server access logging
resource "aws_s3_bucket" "important_data" {
  bucket = "important-data"
  # No logging block
}

# CORRECT: Logging for all important resources
resource "aws_db_instance" "main" {
  identifier = "production-db"

  enabled_cloudwatch_logs_exports = [
    "postgresql",     # Query logs
    "upgrade"
  ]

  # Parameter group for audit logging
  parameter_group_name = aws_db_parameter_group.postgres_audit.name
}

resource "aws_s3_bucket_logging" "important_data" {
  bucket        = aws_s3_bucket.important_data.id
  target_bucket = aws_s3_bucket.access_logs.id  # Logs written to a separate bucket
  target_prefix = "important-data-access/"
}

# CloudTrail for auditing all API calls to the infrastructure
resource "aws_cloudtrail" "main" {
  name                          = "production-trail"
  s3_bucket_name                = aws_s3_bucket.cloudtrail_logs.id
  include_global_service_events = true
  is_multi_region_trail         = true
  enable_log_file_validation    = true  # ← Verify logs weren't modified
}

Mistake 6 — Unlocked Provider Versions #

# ANTI-PATTERN: Unlocked provider version
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
    }
    # No version constraint → always downloads the latest version
    # A breaking change in a new version can break the configuration without warning
  }
}

# ANTI-PATTERN: Overly loose constraint
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 3.0"  # Too loose — could jump to v5 which is breaking
    }
  }
}

# CORRECT: A specific constraint with a lock file
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"  # Caret — allows patch updates but not major
    }
  }
  required_version = ">= 1.6"
}

# .terraform.lock.hcl — commit this to Git!
# Guarantees the exact same provider version in all environments

Security Checklist Before Applying to Production #

TERRAFORM SECURITY CHECKLIST:

CREDENTIALS:
  □ No access keys / secret keys in .tf or .tfvars files
  □ .gitignore includes *.tfstate and *.tfvars containing secrets
  □ CI/CD uses OIDC, not static credentials
  □ Plan and apply roles are separate with different permissions

STATE:
  □ A remote backend is used (not local state)
  □ The state backend is encrypted (S3 with encrypt=true and a KMS key)
  □ State backend access is restricted with strict IAM policies
  □ State locking is enabled

RESOURCE CONFIGURATION:
  □ No security groups with 0.0.0.0/0 on sensitive ports (22, 3306, 5432)
  □ S3 buckets storing data have public access blocks
  □ All storage (RDS, EBS, S3) is encrypted
  □ Logging is enabled for all important resources

POLICY & COMPLIANCE:
  □ Checkov or tfsec runs in the CI pipeline
  □ All resources have the required tags (Environment, Owner)
  □ Provider versions are locked in required_providers
  □ .terraform.lock.hcl is committed to the repository

flowchart TD
    A["Mistake"] --> B["Impact"]
    B --> C["Detection"]
    C --> D["Fix"]

    style A fill:#ef4444,stroke:#dc2626,color:#fff
    style B fill:#f59e0b,stroke:#d97706,color:#fff
    style C fill:#3b82f6,stroke:#1e40af,color:#fff
    style D fill:#10b981,stroke:#059669,color:#fff

Security Scanning Automation #

# CI/CD security scanning pipeline
name: Security Scan
on: [pull_request]

jobs:
  tfsec:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aquasecurity/[email protected]
        with:
          soft_fail: true

  checkov:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: bridgecrewio/checkov-action@v12
        with:
          directory: .
          framework: terraform

Security Review Checklist #

# Pre-deploy security checklist:

# 1. Check there are no secrets in the code
grep -rn "password\|secret\|api_key\|token" --include="*.tf" .
# Should be empty or only references to a secret manager

# 2. Check encryption is active
grep -rn "encrypt" --include="*.tf" .
# Make sure encrypt = true in all backends

# 3. Check public access
grep -rn "0.0.0.0/0\|public" --include="*.tf" .
# Make sure no security groups are open to the public

# 4. Run tfsec
tfsec . --soft-fail
# Review all warnings and errors

# 5. Run checkov
checkov -d . --framework terraform
# Review the compliance checks

Security Hardening Checklist #

# Pre-deploy security checklist:

# 1. Secret scanning
gitleaks detect --source . --verbose

# 2. Infrastructure security scanning
tfsec . --soft-fail
checkov -d . --framework terraform

# 3. Check public access
grep -rn "0.0.0.0/0" --include="*.tf" .

# 4. Check encryption
grep -rn "encrypt.*false" --include="*.tf" .

# 5. Check overly permissive IAM
grep -rn 'Action.*"\*"' --include="*.tf" .

# 6. Check deletion protection
grep -rn "prevent_destroy" --include="*.tf" .

Security Scanning Tools #

# tfsec: Terraform security scanner
brew install tfsec
tfsec . --soft-fail

# checkov: Infrastructure-as-Code scanner
pip install checkov
checkov -d . --framework terraform

# terrascan: Compliance scanner
brew install terrascan
terrascan scan -i terraform

# Integrate into CI/CD
# GitHub Actions:
- name: Run tfsec
  uses: aquasecurity/[email protected]
  with:
    soft_fail: false

- name: Run checkov
  uses: bridgecrewio/checkov-action@v12

Summary #

  • Hardcoding credentials in the configuration is the most dangerous mistake — it permanently enters Git history and must be considered compromised.
  • State files in Git are a security incident — state contains all resource attributes as plaintext, including passwords and API keys.
  • Accidental public resources (S3 without public access blocks, overly permissive security groups) often happen because of insecure defaults — always be explicit.
  • Encryption for all storage — RDS, S3, EBS must all have encryption at rest. Use custom KMS keys instead of the default for better auditing.
  • Logging and monitoring aren’t optional in production — CloudTrail, RDS query logs, and S3 access logs are the minimum that must exist.
  • Use a security checklist before every apply to production — it’s easier to prevent from the start than to fix after an incident.

← Previous: Least Privilege   Next: Monorepo vs Multirepo →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact