Hardening Terraform: Fixing 4 Common AWS Security Blind Spots
Default cloud configurations often prioritize convenience over security, leaving infrastructure vulnerable to automated scanning and compromise.
The promise of Infrastructure as Code (IaC) is consistency, but consistency is a double-edged sword. If your templates contain security blind spots, you will consistently deploy vulnerable infrastructure. Automated scanners constantly sweep public IP ranges and GitHub repositories, looking for open database ports, exposed S3 buckets, and leaked API keys. Within minutes of deployment, an insecure configuration can be discovered and exploited, resulting in unauthorized data access or runaway resource bills.
While AWS has introduced more secure defaults over the years, the Terraform AWS provider often requires explicit configuration to enforce these guardrails. Relying on default resource arguments is a common trap. To build resilient infrastructure, developers must move beyond functional Terraform and write configurations that are secure by default.
1. Eliminating Wildcard IAM Policies
During development, hitting an AccessDenied error is frustrating. The temptation to temporarily set Action: "*" and Resource: "*" in an IAM policy is high, with the promise to clean it up before production. Too often, these permissive policies slip through code reviews.
If an application instance running with an over-privileged IAM role is compromised (for example, via a Server-Side Request Forgery vulnerability), the attacker gains the full permissions of that role. If the role has wildcard permissions, the attacker can escalate privileges, delete backups, or spin up unauthorized resources.
Instead of broad wildcards, IAM policies must enforce the principle of least privilege. This means specifying exact API actions and scoping the resource block to specific Amazon Resource Names (ARNs).
# Insecure: Avoid wildcard permissions
# Action = "*"
# Resource = "*"
# Secure: Explicit actions scoped to a specific resource
resource "aws_iam_policy" "dynamodb_read" {
name = "dynamodb-read-policy"
description = "Allows read-only access to the users table"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"dynamodb:GetItem",
"dynamodb:Query"
]
Resource = "arn:aws:dynamodb:us-east-1:123456789012:table/prod-users"
}
]
})
}
By restricting the policy to dynamodb:GetItem and dynamodb:Query on a specific table ARN, you limit the blast radius. Even if the application server is fully compromised, the attacker cannot delete the table, access other databases, or modify IAM settings.
2. Enforcing S3 Public Access Blocks Explicitly
AWS now blocks public access to new S3 buckets by default when created through the AWS Management Console. However, when provisioning buckets programmatically via Terraform, you must explicitly declare this lockdown. Automated scanners constantly enumerate bucket names, and any bucket left open to the public will be discovered and scraped.
Simply omitting an Access Control List (ACL) or policy is not enough to guarantee privacy, especially if other IAM policies or bucket policies are modified later. The safest approach is to attach an explicit aws_s3_bucket_public_access_block resource to every bucket you provision.
resource "aws_s3_bucket" "app_bucket" {
bucket = "my-company-secure-app-data"
}
resource "aws_s3_bucket_public_access_block" "lockdown" {
bucket = aws_s3_bucket.app_bucket.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
Setting all four parameters to true ensures that even if a developer accidentally attempts to apply a public bucket policy or upload an object with a public ACL, AWS will override and block the action. This acts as a safety net at the account and bucket level.
3. Restricting Database Ingress to Application Security Groups
Opening database ports (such as 5432 for PostgreSQL or 3306 for MySQL) to 0.0.0.0/0 is a shortcut often used to allow developers to connect local database GUIs directly to cloud instances. This exposes the database to brute-force attacks and vulnerability scanning within minutes.
Databases should reside in private subnets with no public IP addresses. Ingress should be restricted strictly to the security groups of the applications that require access, rather than IP ranges.
resource "aws_security_group" "db_sg" {
name = "database-security-group"
vpc_id = var.vpc_id
}
resource "aws_security_group_rule" "db_ingress" {
type = "ingress"
from_port = 5432
to_port = 5432
protocol = "tcp"
security_group_id = aws_security_group.db_sg.id
source_security_group_id = aws_security_group.app_server_sg.id
}
If developers need local access to the database for debugging, they should use AWS Systems Manager Session Manager to establish a secure port-forwarding tunnel. This allows secure, authenticated access through the AWS CLI without exposing any inbound ports to the public internet.
4. Replacing Static CI/CD Keys with OIDC
Using static IAM user access keys and secret keys for CI/CD pipelines (such as GitHub Actions) is a significant security risk. These credentials are long-lived, difficult to rotate, and easily leaked through build logs, compromised developer workstations, or repository misconfigurations.
A more secure approach is to use OpenID Connect (OIDC) federation. This allows your CI/CD platform to assume a temporary IAM role dynamically for each run. The role generates short-lived credentials that expire automatically when the job finishes.
Here is how to configure an OIDC provider and trust relationship for GitHub Actions in Terraform:
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
resource "aws_iam_role" "github_actions" {
name = "github-actions-deploy-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.github.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:my-org/my-repo:*"
}
}
}
]
})
}
With this setup, the GitHub Actions workflow uses the aws-actions/configure-aws-credentials action to request temporary credentials, eliminating the need to store long-lived secrets in your repository settings.
5. Automating Infrastructure Audits
Manually reviewing Terraform code for these security issues is error-prone. Security checks should be integrated directly into the developer workflow using static analysis tools like Trivy.
Trivy can scan your Terraform directory locally or as part of a pre-commit hook to flag open security groups, wildcard policies, and unencrypted resources before they are applied.
# Scan a local Terraform directory for misconfigurations
trivy config ./terraform-dir
Integrating these checks into your pull request pipeline ensures that insecure configurations are blocked before they ever reach your cloud environment.
Securing cloud infrastructure is not about defending against complex, novel exploits. It is about systematically eliminating the basic configuration shortcuts that automated tools exploit. By enforcing explicit S3 public access blocks, restricting database ingress to specific security groups, using least-privilege IAM policies, and adopting OIDC for CI/CD, you eliminate the most common vectors for account compromise.
Sources & further reading
Ji-ho covers the increasingly tangled overlap between cloud architecture and security, drawing on a background as a penetration tester to keep his reporting grounded in real-world attack paths. He never lets a vendor claim go unquestioned and insists that every buzzword come with a proof of concept.
Discussion 0
No comments yet
Be the first to weigh in.