Infrastructure as Code Security
Policy In Code, Not In Hope
In the cloud, consistency is crucial: policy in code, minimal permissions, and visibility into drift.
For Infrastructure as Code Security, success depends on policy-as-code and controls that continuously run alongside the delivery chain.
This way you maintain speed in the cloud, without security depending on manual luck.
Immediate measures (15 minutes)
Why this matters
The core of Infrastructure as Code Security is risk reduction in practice. Technical context supports the choice of measures, but implementation and assurance are central.
Why IaC Security
Infrastructure as Code has fundamentally changed the way we manage infrastructure. Instead of manually configuring servers, we describe our environment in declarative code. That brings advantages — version control, repeatability, auditability — but also new risks.
Infrastructure drift occurs when the actual state of your infrastructure deviates from what is in your code, usually through manual console changes outside the IaC process. Every manual change is a potential misconfiguration that does not go through your security pipeline.
Misconfiguration is attack vector number 1 in the cloud. Not zero-days, not advanced exploits — just misconfigured resources. IaC offers the ability to systematically prevent these misconfigurations, but only if the IaC code itself is secure.
Shift-left security means finding
security flaws before they reach production — already in the
IDE, in the pull request, or in the CI/CD pipeline, long before
terraform apply runs.
Terraform Security
State file protection
The Terraform state file contains the complete mapping between code and infrastructure, including sensitive values such as passwords and access keys — in plain text.
# Secure remote backend configuration with S3 + DynamoDB
terraform {
backend "s3" {
bucket = "company-terraform-state"
key = "production/network/terraform.tfstate"
region = "eu-west-1"
encrypt = true
kms_key_id = "arn:aws:kms:eu-west-1:111111111111:key/abcd-1234"
dynamodb_table = "terraform-state-lock"
}
}
| Measure | Implementation | Priority |
|---|---|---|
| Remote backend | S3, GCS, Azure Blob or Terraform Cloud | Critical |
| Encryption at rest | SSE-KMS (S3), CMEK (GCS) | Critical |
| Locking | DynamoDB (AWS), native (GCS, Azure) | Critical |
| Access control | IAM policies, bucket policies | Critical |
| Versioning | Enable S3 bucket versioning | High |
| State file not in git | Always update .gitignore |
Critical |
Sensitive values and provider credentials
# Mark variables as sensitive
variable "db_password" {
type = string
sensitive = true # Prevents display in CLI output, NOT in state file
}
# WRONG: credentials hardcoded
provider "aws" {
access_key = "AKIAIOSFODNN7EXAMPLE" # NEVER DO THIS
secret_key = "wJalrXUtnFEMI/K7MDENG/bPx" # NEVER DO THIS
}
# CORRECT: credentials from the environment
provider "aws" {
region = "eu-west-1"
# Via AWS_ACCESS_KEY_ID env vars, credential file, instance profile or OIDC
}
Module pinning
# WRONG: without version pinning (supply chain risk)
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
}
# CORRECT: specific version + lock file
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.5.1"
# Commit .terraform.lock.hcl in git for reproducible builds
}
# CORRECT: Git module with specific tag
module "custom" {
source = "git::https://github.com/company/tf-modules.git//vpc?ref=v2.1.0"
}
CloudFormation Security
NoEcho parameters and Secrets Manager
AWSTemplateFormatVersion: '2010-09-09'
Parameters:
DatabasePassword:
Type: String
NoEcho: true
MinLength: 16
Resources:
# Better: let Secrets Manager generate the password
DatabaseSecret:
Type: AWS::SecretsManager::Secret
Properties:
Name: /app/production/db-password
GenerateSecretString:
PasswordLength: 32
ExcludeCharacters: '"@/\'Stack policies
{
"Statement": [
{
"Effect": "Allow",
"Action": "Update:Modify",
"Principal": "*",
"Resource": "*"
},
{
"Effect": "Deny",
"Action": ["Update:Replace", "Update:Delete"],
"Principal": "*",
"Resource": "LogicalResourceId/ProductionDatabase"
}
]
}StackSets guardrails: use SERVICE_MANAGED permission
model with auto-deployment and set
FailureTolerancePercentage=0 so that a single failure stops
the rollout.
Policy as Code
| Framework | Language | Platform | Integration |
|---|---|---|---|
| Open Policy Agent (OPA) | Rego | Multi-cloud | Conftest, Terraform, K8s |
| HashiCorp Sentinel | Sentinel | Terraform Enterprise/Cloud | Native |
| AWS SCP | JSON | AWS Organizations | Native |
| Azure Policy | JSON | Azure | Native |
| Google Org Policies | YAML/JSON | GCP | Native |
Rego example: block public S3 buckets
package terraform.s3
deny[msg] {
resource := input.resource.aws_s3_bucket[name]
resource.acl == "public-read"
msg := sprintf("S3 bucket '%s' has a public ACL.", [name])
}
deny[msg] {
resource := input.resource.aws_s3_bucket_public_access_block[name]
resource.block_public_acls != true
msg := sprintf("S3 bucket '%s' does not block public ACLs.", [name])
}
Sentinel example: mandatory encryption
import "tfplan/v2" as tfplan
mandatory_encryption = rule {
all tfplan.resource_changes as _, rc {
rc.type is "aws_ebs_volume" implies
rc.change.after.encrypted is true
}
}
main = rule { mandatory_encryption }Static analysis tools
| Tool | Supported languages | CI/CD integration | Notable features |
|---|---|---|---|
| tfsec | Terraform (HCL) | GitHub Actions, GitLab CI, Jenkins | Fast, good Terraform coverage |
| Checkov | TF, CFN, K8s, ARM, Helm | All major CI/CD platforms | Broadest language support |
| KICS | 15+ IaC languages | GitHub Actions, GitLab CI | Docker, Ansible, Pulumi |
| Terrascan | TF, K8s, Helm, Dockerfiles | GitHub Actions, ArgoCD | OPA-based |
| Snyk IaC | TF, CFN, K8s, ARM | All major platforms | Fix suggestions, IDE plugins |
Pipeline integration (GitHub Actions)
name: IaC Security Scan
on:
pull_request:
paths: ['terraform/**']
jobs:
iac-security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: tfsec scan
uses: aquasecurity/tfsec-action@v1.0.3
with:
working_directory: terraform/
soft_fail: false
- name: Checkov scan
uses: bridgecrewio/checkov-action@v12
with:
directory: terraform/
framework: terraform
soft_fail: falseDrift Detection
Terraform plan as a detection tool
# Detect drift without applying
terraform plan -detailed-exitcode
# Exit codes: 0 = no drift, 1 = error, 2 = drift detected# GitHub Actions: daily drift detection
name: Drift Detection
on:
schedule:
- cron: '0 6 * * *'
jobs:
detect-drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- name: Detect Drift
run: |
terraform init
terraform plan -detailed-exitcode -no-color
working-directory: terraform/production
continue-on-error: true
# On exit code 2: notification to Slack/TeamsRemediation
| Strategy | When to use | Command |
|---|---|---|
| Import | Manage a manually created resource | terraform import aws_instance.web i-123456 |
| Remove from state | No longer manage resource via Terraform | terraform state rm aws_instance.legacy |
| Force apply | Terraform is the source of truth | terraform apply |
Secrets in IaC
Secrets do not belong in IaC code or state files. Yet this is one of the most common mistakes: deleting a secret from the current version does not remove it from the git history.
# Option 1: HashiCorp Vault
data "vault_generic_secret" "database" {
path = "secret/production/database"
}
resource "aws_db_instance" "main" {
username = data.vault_generic_secret.database.data["username"]
password = data.vault_generic_secret.database.data["password"]
}
# Option 2: AWS Secrets Manager
data "aws_secretsmanager_secret_version" "db_creds" {
secret_id = "production/database/credentials"
}
# Option 3: Environment variables
# export TF_VAR_db_password="secret"
# Terraform automatically reads TF_VAR_* variables
Pre-commit hooks and .gitignore
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks# Terraform state and secrets
*.tfstate
*.tfstate.*
*.tfvars
*.tfvars.json
!example.tfvars
.terraform/
crash.log
CI/CD pipeline for IaC
A secure IaC pipeline follows the principle: plan in the pull request, apply only after approval.
name: Terraform Pipeline
on:
pull_request:
paths: ['terraform/**']
push:
branches: [main]
paths: ['terraform/**']
permissions:
contents: read
pull-requests: write
id-token: write
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform fmt -check -recursive
- run: terraform init -backend=false && terraform validate
- uses: aquasecurity/tfsec-action@v1.0.3
plan:
needs: validate
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111111111111:role/TerraformPlanRole
aws-region: eu-west-1
- run: terraform init && terraform plan -no-color
apply:
needs: validate
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production # Requires manual approval
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111111111111:role/TerraformApplyRole
aws-region: eu-west-1
- run: terraform init && terraform apply -auto-approveBranch protection rules: at least 1 approval, security scan must
pass, no direct pushes to main.
Common Mistakes
| Mistake | Risk | Solution |
|---|---|---|
| State file in git | All secrets visible in version control | Remote backend with encryption |
| Credentials in HCL/YAML | Hardcoded secrets | Environment variables or vault |
acl = "public-read" on S3 |
Data breach | Policy as Code blocking |
Wildcard IAM policies (*) |
Privilege escalation | Least privilege, tfsec rules |
| No module pinning | Supply chain attack | Version pinning + lock file |
| Local state file | No locking/encryption/backup | Enforce remote backend |
apply without plan review |
Destructive changes | Plan-in-PR workflow |
| No drift detection | Changes go unnoticed | Scheduled plan checks |
.tfvars with secrets in git |
Passwords in plain text | Vault, SOPS, or env vars |
Security groups 0.0.0.0/0 |
Unrestricted network access | Checkov/tfsec + SCP |
| No state locking | State corruption | DynamoDB or native locking |
destroy without protection |
Entire environment gone | prevent_destroy + stack policies |
Checklist
| # | Measure | Priority |
|---|---|---|
| 1 | Remote backend with encryption for state files | Critical |
| 2 | No credentials in IaC code | Critical |
| 3 | State locking enabled | Critical |
| 4 | .gitignore for state files and
.tfvars |
Critical |
| 5 | Pre-commit hooks for secret detection (gitleaks) | High |
| 6 | Static analysis in CI/CD pipeline (tfsec/Checkov) | High |
| 7 | Plan-in-PR, apply-after-approval workflow | High |
| 8 | Module version pinning with lock file | High |
| 9 | OIDC federation for CI/CD credentials | High |
| 10 | Branch protection on IaC repositories | High |
| 11 | Policy as Code (OPA/Sentinel/SCP) | High |
| 12 | External secret management (Vault/Secrets Manager) | High |
| 13 | Daily drift detection | Medium |
| 14 | sensitive = true on sensitive variables and
outputs |
Medium |
| 15 | prevent_destroy on critical resources |
Medium |
| 16 | Stack policies for CloudFormation | Medium |
| 17 | State file access auditing | Medium |
| 18 | Terraform version pinning (required_version) |
Medium |
We automated our infrastructure. Fantastic. We also automated our
misconfigurations, but at scale and with version control so we can admire
them forever. The Terraform state file is a masterpiece of irony: a file
designed to manage your infrastructure, but that simultaneously stores every
secret in plain text as if it were 1999. In the old days, a mistake on
a server could at most break that one server. Now a single
git push to main — followed by an auto-apply pipeline
that someone set up "for convenience" — can level an entire AWS account.
We call that progress. The state file, that sacred document, is still
stored locally by half of all teams or accidentally committed to git,
complete with database passwords and API keys. And when you ask why there
is no encryption on it, you hear: "That's on the backlog." Just like
the rest of the security.
Summary
Infrastructure as Code is a powerful tool for making infrastructure manageable, repeatable, and auditable — but only if the code itself is secure. The core of IaC security rests on four pillars: protect your state files as if they are crown jewels (remote backend, encryption, locking), keep secrets out of your code and version control (vault, environment variables, SOPS), automatically scan your configurations for misconfigurations (tfsec, Checkov, Policy as Code), and set up a secure pipeline with plan review and approval gates. Drift detection prevents manual changes from undermining your security baseline. Without these measures, you are not just automating your infrastructure, but also your vulnerabilities.
In the next chapter, we cover secrets management: how to securely store, rotate, and distribute sensitive values across your cloud environment without them ending up in code, logs, or state files.
Further reading in the knowledge base
These articles in the portal provide more background and practical context:
- The cloud — someone else's computer, your responsibility
- Containers and Docker — what it is and why you need to secure it
- Encryption — the art of making things unreadable
- Least Privilege — give people only what they need
You need an account to access the knowledge base. Log in or register.
Related security measures
These articles provide additional context and depth: