Security incidents are expensive. A single breach costs an average of $4.45 million, according to IBM’s 2023 report.
Most breaches don’t require sophisticated attacks—they exploit basic misconfigurations. Public S3 buckets. Overly permissive IAM roles. Unpatched systems.
Here’s how to close those gaps.
1. IAM: Principle of Least Privilege
IAM (Identity and Access Management) is the foundation of AWS security.
Enable MFA Everywhere
Every human user needs multi-factor authentication:
# AWS CLI: Enforce MFA for all users
aws iam create-policy \
--policy-name EnforceMFA \
--policy-document file://enforce-mfa.json
enforce-mfa.json:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyAllExceptMFAAuth",
"Effect": "Deny",
"NotAction": [
"iam:CreateVirtualMFADevice",
"iam:EnableMFADevice",
"iam:GetUser",
"iam:ListMFADevices",
"iam:ListVirtualMFADevices",
"iam:ResyncMFADevice"
],
"Resource": "*",
"Condition": {
"BoolIfExists": {
"aws:MultiFactorAuthPresent": "false"
}
}
}
]
}
Attach this policy to all users. If they don’t have MFA enabled, they can’t do anything (except set up MFA).
Use Roles, Not Access Keys
Access keys (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY) are dangerous:
- They don’t expire automatically
- They’re easy to leak (git commits, logs, error messages)
- They’re hard to rotate
Instead: Use IAM roles with instance profiles or OIDC federation.
For EC2:
# Terraform: EC2 instance with IAM role
resource "aws_iam_role" "app_role" {
name = "app-server-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
}]
})
}
resource "aws_iam_instance_profile" "app_profile" {
name = "app-server-profile"
role = aws_iam_role.app_role.name
}
resource "aws_instance" "app" {
ami = var.ami_id
instance_type = "t3.medium"
iam_instance_profile = aws_iam_instance_profile.app_profile.name
# No access keys needed!
}
The app gets temporary credentials automatically.
For GitHub Actions (OIDC):
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["..."]
}
resource "aws_iam_role" "github_actions" {
name = "github-actions-deploy"
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:myorg/myrepo:*"
}
}
}]
})
}
Now GitHub Actions uses short-lived tokens. No access keys to leak.
Audit IAM Policies Regularly
Unused permissions accumulate. Use IAM Access Analyzer to find them:
aws accessanalyzer create-analyzer \
--analyzer-name my-account-analyzer \
--type ACCOUNT
It identifies:
- Unused roles and policies
- Overly broad permissions
- External access (resources shared outside your account)
Review quarterly and remove unused permissions.
2. Network Security
VPC Configuration
Never put databases in public subnets.
# Terraform: Secure multi-tier VPC
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
}
# Public subnets (ALB, NAT gateways)
resource "aws_subnet" "public" {
count = 3
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 4, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
}
# Private subnets (app servers, databases)
resource "aws_subnet" "private" {
count = 3
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 4, count.index + 3)
availability_zone = data.aws_availability_zones.available.names[count.index]
}
Databases in private subnets can’t be reached from the internet. App servers in private subnets use NAT gateways for outbound access only.
Security Groups: Default Deny
Start with zero access, then add what’s needed:
resource "aws_security_group" "app" {
name_prefix = "app-"
vpc_id = aws_vpc.main.id
# Allow inbound from load balancer only
ingress {
from_port = 8080
to_port = 8080
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
# Allow outbound to database
egress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.db.id]
}
# Allow outbound HTTPS for API calls
egress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
Don’t use 0.0.0.0/0 for ingress unless absolutely necessary (and even then, think twice).
Enable VPC Flow Logs
Capture all network traffic for forensics:
resource "aws_flow_log" "main" {
log_destination = aws_s3_bucket.flow_logs.arn
log_destination_type = "s3"
traffic_type = "ALL"
vpc_id = aws_vpc.main.id
}
When something goes wrong, flow logs show what connected to what.
3. Data Protection
Encrypt Everything at Rest
S3:
resource "aws_s3_bucket" "data" {
bucket = "my-secure-bucket"
}
resource "aws_s3_bucket_server_side_encryption_configuration" "data" {
bucket = aws_s3_bucket.data.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.data.arn
}
}
}
# Block public access
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
}
RDS:
resource "aws_db_instance" "main" {
identifier = "myapp-db"
engine = "postgres"
storage_encrypted = true
kms_key_id = aws_kms_key.rds.arn
# Snapshots are encrypted too
}
Use Secrets Manager or Parameter Store
Never hardcode secrets:
resource "aws_secretsmanager_secret" "db_password" {
name = "prod/db/master-password"
}
resource "aws_secretsmanager_secret_version" "db_password" {
secret_id = aws_secretsmanager_secret.db_password.id
secret_string = jsonencode({
username = "admin"
password = random_password.db.result
})
}
# App reads from Secrets Manager
# Credentials rotate automatically
Enable automatic rotation for database credentials.
4. Logging and Monitoring
Enable CloudTrail
CloudTrail logs all API calls—essential for auditing and forensics:
resource "aws_cloudtrail" "main" {
name = "main-trail"
s3_bucket_name = aws_s3_bucket.cloudtrail.id
include_global_service_events = true
is_multi_region_trail = true
enable_log_file_validation = true
}
Log file validation ensures logs haven’t been tampered with.
Set Up AWS Config
AWS Config tracks configuration changes:
resource "aws_config_configuration_recorder" "main" {
name = "main-recorder"
role_arn = aws_iam_role.config.arn
recording_group {
all_supported = true
}
}
You can write rules like:
- “Alert if any S3 bucket becomes public”
- “Alert if MFA is disabled on root account”
- “Alert if security groups allow 0.0.0.0/0 on SSH”
GuardDuty for Threat Detection
Enable GuardDuty—it analyzes CloudTrail, VPC Flow Logs, and DNS logs to detect threats:
aws guardduty create-detector --enable
It alerts on things like:
- EC2 instances communicating with known malicious IPs
- Unusual API calls (e.g., someone trying to exfiltrate data)
- Compromised credentials
5. Patch Management
Unpatched systems are low-hanging fruit for attackers.
Use AWS Systems Manager
Automate patching:
resource "aws_ssm_maintenance_window" "patching" {
name = "patching-window"
schedule = "cron(0 2 ? * SUN *)" # 2 AM every Sunday
duration = 3
cutoff = 1
}
resource "aws_ssm_maintenance_window_task" "patch" {
window_id = aws_ssm_maintenance_window.patching.id
task_type = "RUN_COMMAND"
task_arn = "AWS-RunPatchBaseline"
priority = 1
service_role_arn = aws_iam_role.ssm.arn
targets {
key = "tag:Environment"
values = ["production"]
}
}
Patches are applied weekly. Automatically.
6. Compliance Frameworks
SOC 2 / ISO 27001 Readiness
If you’re pursuing compliance:
Must-haves:
- Encryption at rest and in transit
- MFA for all human access
- Audit logging (CloudTrail, VPC Flow Logs, application logs)
- Access reviews (quarterly)
- Incident response plan
- Disaster recovery plan
Use tools like AWS Security Hub or Prowler to audit your setup against compliance frameworks.
Quick Wins
If you do nothing else, do these:
- Enable MFA for all users
- Block public access on all S3 buckets
- Enable CloudTrail
- Use IAM roles instead of access keys
- Encrypt everything (RDS, S3, EBS)
These five changes prevent 80% of common security issues.
The Bottom Line
Security isn’t a one-time project—it’s ongoing. But starting with solid fundamentals makes a massive difference.
Review your security posture quarterly. Threats evolve; so should your defenses.