12 AWS Defaults That Ship Insecure (and the One Line That Fixes Each)
DEV Community

12 AWS Defaults That Ship Insecure (and the One Line That Fixes Each)

12 AWS Defaults That Ship Insecure (and the One Line That Fixes Each)

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.

CloudTrail Retention Window

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.

RDS Storage Encryption

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 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.

PostgreSQL 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.

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"
}

Load Balancer TLS Version

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
drop_invalid_header_fields = true

Orphaned VPC Flow Logs

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.vpc.arn
  iam_role_arn = aws_iam_role.flow_log.arn
}

ACCEPT only or REJECT only is half an audit trail.

Unwanted Service Log Groups

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.data.arn
  logs.arn = aws_cloudwatch_log_group.rds.arn
}

Default Security Group Permissions

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.

RDS Backup Configuration

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.

EBS Encryption Per Region

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
}

SNS Alert Encryption

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.

The fix:

resource "aws_sns_topic" "alerts" {
  name = "security-alerts"
  kms_master_key_id = aws_kms_key.data.arn
  logs.arn = aws_cloudwatch_log_group.alerts.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.

S3 Bucket Encryption

Default: Since January 2023 every new object is encrypted with SSE-S3. Since April 2023 new buckets get Block Public Access on and ACLs off. The two most famous S3 footguns are gone for new buckets.

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
  • A bucket policy denying aws:SecureTransport = false

The 2023 change is your floor, not your control.

ECS Exec Shell Logging

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_ENCRYPTED and VPC_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. Checkov in 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.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.