Every AWS default answers exactly one question: will the tutorial work?
No surprise bill. No failed API call. No "access denied" on step three. That is a great default for a tutorial. It is a terrible default for the thing you spun up "just for staging" that is now production, on the day someone asks who downloaded that bucket in March.
I run infrastructure for a healthcare company. People with clipboards read my configs. Somewhere around my third Terraform module I noticed my job had a shape, and the shape was not "design clever things". It was flipping the same switches AWS had left in the wrong position, over and over. So I wrote them down.
Here are the twelve, ranked by how likely each one is to hurt you. One rule of thumb before we start: checkov and tfsec check what you wrote. Most of this list is about what you did not write.
1. CloudTrail remembers 90 days, then forgets
Default: you get "Event history": 90 days of management events, viewable in the console. No trail to S3 unless you create one. And S3 object reads and writes (data events) are not logged at all, even when a trail exists.
How it bites: a credential gets phished in March. In September someone asks what it touched. The window scrolled off in June, and even inside the window "did they download the sensitive bucket" was never recorded. Your answer is a shrug.
The fix:
resource "aws_cloudtrail" "audit" {
name = "org-audit"
s3_bucket_name = aws_s3_bucket.audit.id
is_multi_region_trail = true
enable_log_file_validation = true
event_selector {
read_write_type = "All"
data_resource {
type = "AWS::S3::Object"
values = ["arn:aws:s3:::my-sensitive-bucket/"]
}
}
}
Data events cost real money at volume. Scope them on purpose, not by forgetting.
2. RDS storage is unencrypted, and you cannot fix that later
Default: storage_encrypted = false in Terraform and in the API. The console nudges you. Code does not. And encryption can only be enabled at creation. Later means snapshot, encrypted copy, restore, cutover.
How it bites: this is the worst one on the list because it is irreversible in place. Staging quietly becomes production, it always does, and eighteen months later a review finds your primary database in plaintext. The remediation is a maintenance window on your busiest system.
The fix:
storage_encrypted = true
kms_key_id = aws_kms_key.data.arn
In my modules this is hardcoded, not a variable. A security invariant that callers can toggle off is not an invariant. It is a default waiting to come back.
3. Your Postgres accepts plaintext connections
Default: on RDS for PostgreSQL 14 and earlier, rds.force_ssl is 0. The server supports TLS. It also cheerfully accepts connections without it. AWS flipped this to 1 for PostgreSQL 15+, which tells you what AWS thinks of the old default.
How it bites: any client on sslmode=prefer (the libpq default) falls back to plaintext if the handshake hiccups. Nothing fails. Nothing logs. Your transmission security now depends on every developer, sidecar, and ad hoc psql from a bastion remembering a flag.
The fix: enforce it at the engine so client config stops mattering:
parameter {
name = "rds.force_ssl"
value = "1"
}
4. Your load balancer still shakes hands with 2008
Default: create an HTTPS listener without a policy and you get ELBSecurityPolicy-2016-08, which accepts TLS 1.0 and 1.1. Also off by default on the same resource: access logs and deletion protection.
How it bites: nobody notices, because modern browsers pick 1.2+. Then a customer's security team runs an external scan before signing, and the deal stalls on a finding one attribute would have prevented.
The fix:
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
And on the aws_lb itself: access_logs { enabled = true }, enable_deletion_protection = true, and drop_invalid_header_fields = true while you are there.
5. VPC Flow Logs do not exist
Default: no VPC has flow logs until you create them. No network-level record of anything.
How it bites: GuardDuty flags an instance talking to a known-bad IP. The first question is "since when, and what else did it talk to?" Without flow logs your incident response runs on vibes, and "was anything exfiltrated?" gets answered conservatively, which means expensively.
The fix:
resource "aws_flow_log" "vpc" {
vpc_id = aws_vpc.main.id
traffic_type = "ALL"
log_destination_type = "cloud-watch-logs"
log_destination = aws_cloudwatch_log_group.flow.arn
iam_role_arn = aws_iam_role.flow.arn
}
ACCEPT only or REJECT only is half an audit trail.
6. Services create log groups you never asked for
Default: log groups default to Never Expire and an AWS-managed key. The sneaky part: RDS log exports, Container Insights, and Lambda create their own log groups on first write, with exactly those defaults, outside your Terraform state.
How it bites: you enable RDS log exports. RDS creates /aws/rds/instance/.../postgresql for you. Three years later that orphan holds three years of connection logs plus whatever your app leaked into query errors, retained forever, under a key you did not choose, and invisible to terraform destroy.
The fix: pre-create every log group a service will write to, before the service exists:
resource "aws_cloudwatch_log_group" "rds" {
name = "/aws/rds/instance/myapp-db/postgresql"
retention_in_days = 2192
kms_key_id = aws_kms_key.logs.arn
}
7. The default security group is a party where everyone knows everyone
Default: every VPC ships with a default security group that allows all traffic between members and all outbound. Anything launched without an explicit group lands in it.
How it bites: a contractor spins up a "quick utility box" without thinking about security groups. It joins the default one, alongside everything else that drifted in over the years. That box gets popped, and lateral movement is free, because membership is the authorization.
The fix: you cannot delete the default group, but you can strip it bare. A resource with no ingress or egress blocks removes every rule, so anything landing there can talk to precisely nothing:
resource "aws_default_security_group" "this" {
vpc_id = aws_vpc.main.id
tags = { Name = "default-DO-NOT-USE" }
}
Loud failure beats silent success.
8. RDS backups: one day via the API, zero via Terraform
Default: backup retention is one day via API or CLI, seven via the console, and backup_retention_period defaults to 0 in Terraform. Zero. Automated backups off. Deletion protection is also off.
How it bites: an instance defined without that line has no backups at all, and it passes plan, apply, and review, because absence does not show up in a diff. You find out during your first real restore attempt, which is the single worst moment available. Bonus: with deletion protection off, one terraform destroy in the wrong workspace takes the database and its backups together.
The fix:
backup_retention_period = 30
deletion_protection = true
skip_final_snapshot = false
final_snapshot_identifier = "myapp-db-final"
Then AWS Backup with a locked vault on top, because backups attached to the instance share the instance's blast radius.
9. EBS encryption by default is off, in every region separately
Default: account-level EBS encryption by default is disabled, and it is a per-region setting. Turning it on in us-east-1 does nothing for us-west-2.
How it bites: your Terraform encrypts every volume it manages. Then someone launches a console instance for a one-off migration, copies a database dump onto it, and that volume is plaintext, because the account default governs ad hoc resources, not your module.
The fix: one resource, once per region you use, including the ones you think you do not use:
resource "aws_ebs_encryption_by_default" "this" {
enabled = true
}
10. SNS stores your alerts in plaintext
Default: server-side encryption on SNS topics is off until you set a KMS key.
How it bites: mostly as an audit finding. Occasionally worse: a team wires appointment reminders through SNS, and now message bodies with patient data sit unencrypted in the messaging layer while every database in the stack is dutifully encrypted. One-line fix or an hours-long finding memo. Pick the line.
The fix:
resource "aws_sns_topic" "alerts" {
name = "security-alerts"
kms_master_key_id = aws_kms_key.logs.arn
}
Real gotcha: once the topic is encrypted, CloudWatch and EventBridge need kms:Decrypt and kms:GenerateDataKey* in the key policy, not in IAM. Otherwise every alarm publish fails silently. Test the path end to end.
11. S3 got fixed, which is exactly the problem
Default (current): since January 2023 every new object is encrypted with SSE-S3. Since April 2023 new buckets get Block Public Access on and ACLs off. Credit where due: the two most famous S3 footguns are gone for new buckets. Still off: server access logging, versioning, and any customer-managed key.
How it bites: "S3 encrypts by default now" becomes the reason nobody configures anything further. A sensitive bucket ends up with no access log, no versioning, no policy denying plaintext transport, and a key nobody can audit.
The fix: for a bucket that matters: SSE-KMS with your own key, versioning on, aws_s3_bucket_logging to a separate log bucket, and a bucket policy denying aws:SecureTransport = false. The 2023 change is your floor, not your control.
12. ECS Exec gives you a shell, and records nothing
Default: ECS Exec logging is DEFAULT, meaning "whatever awslogs config the task has". If the container has none, session input and output are not logged at all. KMS encryption of the session channel is opt-in.
How it bites: an engineer execs into a production task to debug, runs a few queries, pastes results somewhere. Months later an access review needs that session. CloudTrail says a shell was opened at 14:32. That is the entire record.
The fix: on the cluster, execute_command_configuration with logging = "OVERRIDE", a dedicated encrypted log group, and a kms_key_id for the channel. Every session becomes a transcript.
Automate it or relive it forever
Twelve items is too many for humans to re-run reliably. That is the real lesson. What works for me:
-
One baseline module for the apply-once account controls: the trail, AWS Config rules like
RDS_STORAGE_ENCRYPTEDandVPC_FLOW_LOGS_ENABLED, EBS default encryption, the account-level S3 public access block. -
Hardcoded invariants in workload modules.
storage_encrypted,publicly_accessible = false, forced TLS, and log validation are not variables in mine. Exposing them as inputs just recreates the permissive default one level up, with your name on it. -
Policy as code for the rest.
checkovin CI catches regressions in code. AWS Config catches the console-created, script-created, "temporary" resources your code never met. You need both, because the resources that hurt most never saw a pull request.
The pattern across all twelve is the same. AWS optimizes for the first five minutes. Your auditor cares about year five, when someone asks who accessed what and the answer has to exist. Those two goals produce opposite defaults, and closing the gap is, by AWS's own shared responsibility model, your job.
Not legal or compliance advice. I am an infrastructure engineer, and defaults change, sometimes even in the right direction, so check each one against current docs.
Which default bit you? I only know the twelve that bit me.
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.