← Back to blog

2026-07-30 · 8 min read

AWS Security Baseline in One Terraform Apply

Five Terraform patterns that set up AWS the right way from day one, Secrets Manager, least-privilege IAM, CodeConnections, KMS, and a full security baseline (GuardDuty, Security Hub, Config, Access Analyzer, CloudTrail).

#aws#terraform#security#iam#guardduty#devsecops
AWS Security Baseline in One Terraform Apply

Most AWS accounts start insecure. Not because engineers are careless, because the defaults are permissive and the console makes it too easy to click "full access" and move on. By the time a team circles back to security, there are wildcard IAM policies everywhere, long-lived tokens in CI, unencrypted secrets, and zero visibility into what's actually happening in the account.

I built aws-secure-foundations to fix that. It's five small, CI-validated Terraform modules that teach, and enforce, the right way to use core AWS security services from day one. Each example is standalone, production-ready, and designed to be copy-pasted into your own infrastructure.

The Five Patterns

1. Secrets Manager: Scoped to a Single Secret ARN

The antipattern: granting secretsmanager:GetSecretValue on *. That means any principal with that policy can read every secret in your account.

The fix: scope access to the exact ARN of the secret the workload needs.

resource "aws_secretsmanager_secret" "db_password" {
  name        = "myapp/production/db-password"
  description = "RDS master password for the production database"

  kms_key_id = aws_kms_key.secrets.arn
}

resource "aws_iam_policy" "read_db_secret" {
  name = "read-db-secret"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid      = "AllowReadOneSecret"
        Effect   = "Allow"
        Action   = ["secretsmanager:GetSecretValue"]
        Resource = [aws_secretsmanager_secret.db_password.arn]
      },
      {
        Sid      = "AllowDecryptWithSecretsKey"
        Effect   = "Allow"
        Action   = ["kms:Decrypt"]
        Resource = [aws_kms_key.secrets.arn]
      }
    ]
  })
}

Two things to notice: the resource is a single ARN (not *), and we explicitly grant kms:Decrypt on the customer-managed key that encrypts the secret. No implicit grants, no ambient permissions.

2. Least-Privilege IAM: No Wildcards

Wildcard actions (s3:*, ec2:*) and wildcard resources are the number-one finding in every AWS security audit. The pattern here is simple but requires discipline: name every action, scope every resource.

resource "aws_iam_role" "lambda_processor" {
  name = "lambda-order-processor"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy" "lambda_permissions" {
  name = "order-processor-permissions"
  role = aws_iam_role.lambda_processor.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid      = "ReadOrdersTable"
        Effect   = "Allow"
        Action   = [
          "dynamodb:GetItem",
          "dynamodb:Query"
        ]
        Resource = [
          aws_dynamodb_table.orders.arn,
          "${aws_dynamodb_table.orders.arn}/index/*"
        ]
      },
      {
        Sid      = "WriteProcessedBucket"
        Effect   = "Allow"
        Action   = ["s3:PutObject"]
        Resource = ["${aws_s3_bucket.processed.arn}/orders/*"]
      },
      {
        Sid      = "BasicLambdaLogs"
        Effect   = "Allow"
        Action   = [
          "logs:CreateLogGroup",
          "logs:CreateLogStream",
          "logs:PutLogEvents"
        ]
        Resource = ["arn:aws:logs:*:*:/aws/lambda/order-processor*"]
      }
    ]
  })
}

Every action is enumerated. Every resource is scoped to the exact table, bucket prefix, or log group the function touches. If the Lambda gets compromised, the blast radius is one DynamoDB table and one S3 prefix, not the entire account.

3. CodeConnections: Zero Long-Lived Tokens

AWS CodeConnections (formerly CodeStar Connections) replaces the old model of pasting GitHub Personal Access Tokens into CodePipeline or CodeBuild. Instead of a static token that never expires and has broad repo access, CodeConnections uses an OAuth-based flow with short-lived credentials scoped to specific repositories.

resource "aws_codeconnections_connection" "github" {
  name          = "github-org-connection"
  provider_type = "GitHub"
}

resource "aws_codepipeline" "deploy" {
  name     = "deploy-pipeline"
  role_arn = aws_iam_role.pipeline.arn

  stage {
    name = "Source"

    action {
      name             = "GitHub"
      category         = "Source"
      owner            = "AWS"
      provider         = "CodeStarSourceConnection"
      version          = "1"
      output_artifacts = ["source"]

      configuration = {
        ConnectionArn    = aws_codeconnections_connection.github.arn
        FullRepositoryId = "my-org/my-app"
        BranchName       = "main"
      }
    }
  }

  # ... build and deploy stages
}

Why this matters:

  • No secrets to rotate. There's no PAT stored in Secrets Manager or SSM that someone forgets to rotate.
  • Scoped access. The connection is authorized for specific repos, not your entire GitHub org.
  • Auditability. AWS CloudTrail logs every API call against the connection.
  • Revocation. Disconnect the connection once in the AWS console and every pipeline using it stops: no hunting for where a token was pasted.

After running terraform apply, you complete a one-time OAuth handshake in the console to activate the connection. From that point on, credentials are managed entirely by AWS.

4. Customer-Managed KMS Keys: Tight Key Policies

The default AWS-managed keys (aws/s3, aws/ebs, etc.) work, but you can't control who decrypts, you can't audit key usage independently, and you can't revoke access without modifying IAM policies. Customer-managed keys give you a separate control plane.

resource "aws_kms_key" "data" {
  description             = "Encrypt application data at rest"
  deletion_window_in_days = 30
  enable_key_rotation     = true

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid       = "RootAccountFullAccess"
        Effect    = "Allow"
        Principal = { AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" }
        Action    = "kms:*"
        Resource  = "*"
      },
      {
        Sid       = "AllowEncryptDecryptForApp"
        Effect    = "Allow"
        Principal = { AWS = aws_iam_role.app.arn }
        Action    = [
          "kms:Encrypt",
          "kms:Decrypt",
          "kms:GenerateDataKey"
        ]
        Resource = "*"
      },
      {
        Sid       = "DenyEveryoneElse"
        Effect    = "Deny"
        Principal = "*"
        Action    = "kms:*"
        Resource  = "*"
        Condition = {
          StringNotEquals = {
            "aws:PrincipalArn" = [
              "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root",
              aws_iam_role.app.arn
            ]
          }
        }
      }
    ]
  })
}

resource "aws_kms_alias" "data" {
  name          = "alias/app-data-key"
  target_key_id = aws_kms_key.data.key_id
}

The explicit deny statement is the key (no pun intended). Even if someone attaches a permissive IAM policy to another role, the key policy blocks them. This is defense in depth, IAM says yes, but the resource policy says no.

5. Account Security Baseline: One Apply

This is the crown jewel. A single terraform apply enables five AWS security services that every account should have running from the moment it's created:

# GuardDuty: threat detection
resource "aws_guardduty_detector" "main" {
  enable = true

  datasources {
    s3_logs      { enable = true }
    kubernetes   { audit_logs { enable = true } }
    malware_protection { scan_ec2_instance_with_findings { ebs_volumes { enable = true } } }
  }
}

# Security Hub: aggregated findings + CIS/AWS benchmarks
resource "aws_securityhub_account" "main" {}

resource "aws_securityhub_standards_subscription" "cis" {
  depends_on    = [aws_securityhub_account.main]
  standards_arn = "arn:aws:securityhub:::ruleset/cis-aws-foundations-benchmark/v/1.4.0"
}

# AWS Config: resource inventory + compliance rules
resource "aws_config_configuration_recorder" "main" {
  name     = "default"
  role_arn = aws_iam_role.config.arn

  recording_group {
    all_supported = true
  }
}

resource "aws_config_delivery_channel" "main" {
  name           = "default"
  s3_bucket_name = aws_s3_bucket.config.id
  depends_on     = [aws_config_configuration_recorder.main]
}

resource "aws_config_configuration_recorder_status" "main" {
  name       = aws_config_configuration_recorder.main.name
  is_enabled = true
  depends_on = [aws_config_delivery_channel.main]
}

# IAM Access Analyzer: find unintended public/cross-account access
resource "aws_accessanalyzer_analyzer" "main" {
  analyzer_name = "account-analyzer"
  type          = "ACCOUNT"
}

# CloudTrail: API audit log for every action in the account
resource "aws_cloudtrail" "main" {
  name                          = "org-trail"
  s3_bucket_name                = aws_s3_bucket.trail.id
  include_global_service_events = true
  is_multi_region_trail         = true
  enable_log_file_validation    = true
  kms_key_id                    = aws_kms_key.trail.arn
}

After one terraform apply, you have:

  • GuardDuty scanning VPC flow logs, DNS queries, CloudTrail events, S3 data events, and EBS volumes for threats.
  • Security Hub scoring your account against the CIS AWS Foundations Benchmark.
  • AWS Config recording every resource change and checking compliance rules.
  • IAM Access Analyzer continuously scanning resource policies for unintended external access.
  • CloudTrail logging every API call across all regions, encrypted with a customer-managed key, with log file integrity validation.

That's full account visibility in under 60 seconds of apply time.

Why This Exists

I kept seeing the same failure mode: teams spin up an account, build the application, ship to production, and then, maybe, get around to security six months later. By then the damage is done: overly permissive roles are baked into CI, secrets are in environment variables, and nobody knows who did what because CloudTrail wasn't enabled.

These five modules flip the order. Security goes in first, as code, validated by CI on every pull request. The Terraform is intentionally simple, no complex abstractions, no wrapper modules with 40 variables. You can read each file top to bottom and understand exactly what it does.

Key Takeaways

  1. Scope everything to the narrowest resource. Single secret ARN, single table, single bucket prefix. Never *.
  2. Customer-managed KMS keys are a separate control plane. Use them for anything sensitive. The explicit deny in the key policy is your safety net.
  3. CodeConnections eliminates an entire class of credential-rotation problems. If you're still using PATs in CI, migrate today.
  4. Enable security services on day zero. GuardDuty, Security Hub, Config, Access Analyzer, and CloudTrail cost very little relative to the visibility they provide.
  5. Make it one command. If the security baseline requires a runbook, it won't get applied consistently. terraform apply is the entire runbook.

Every module in the repo runs terraform validate and tflint in CI. If you break a pattern, add a wildcard, remove a condition, the pipeline catches it before it merges.


The full source is open and ready to fork:

github.com/durrello/aws-secure-foundations

Clone it, adapt the variables to your account, and ship a secure baseline before you ship your first feature.

Share:LinkedInXWhatsApp

Related articles

Reactions & comments