How to Implement SOC 2 Controls on AWS with Terraform (2026)

Oct 31, 2026 · 18 min · Ajay Kumar

This guide is for the engineer who has just been told "we're doing SOC 2" and owns the AWS organisation. At the end you will have Terraform that turns on the technical controls auditors test most often on AWS: an organisation-wide CloudTrail into a locked log archive, AWS Config with a SOC 2-mapped conformance pack, GuardDuty and Security Hub CSPM run from a delegated audit account, short Identity Center sessions with MFA, encryption and public-access defaults, AWS Backup with a vault lock, 400-day retention, and commands that export evidence. I ran terraform validate on every block against AWS provider 6.71.0 (released 28 October 2026) with Terraform CLI 1.12.0; 1.17.1 is current. You need AWS Organizations with all features enabled, a management account, and two member accounts called log archive and audit. If you want the why before the how, read the sibling post, SOC 2 for Startups in 2026.

What SOC 2 needs from your AWS accounts

SOC 2 does not name AWS services. The common criteria ask for outcomes: logical access controls (CC6), monitoring and incident detection (CC7), controlled change (CC8). Auditors then test what you configured to meet them. Two facts shape the design. First, there is no AWS-made SOC 2 conformance pack: the AWS Audit Manager availability page lists "SSAE-18 SOC 2" with "No equivalent". Second, that same page records that Audit Manager stopped accepting new customers on 30 April 2026, so the evidence trail has to come from AWS Config, Security Hub CSPM and CloudTrail directly, or from a compliance platform reading them.

SOC 2 area AWS control in this guide Evidence the auditor gets
CC6.1 logical access Identity Center MFA, 1-hour admin sessions, centralised root access Config rule results, permission set definitions
CC6.1 data at rest EBS encryption by default, KMS for logs, RDS encryption rule Config rule results, select-resource-config export
CC6.6 boundaries S3 account-level Block Public Access Config rule results, Security Hub S3.1
CC7.2 monitoring Org CloudTrail, GuardDuty, Security Hub CSPM Trail digest validation, findings export
A1.2 recovery AWS Backup plan, vault lock Backup jobs, restore test records
CC7.2 retention 400-day S3 lifecycle and log group retention Lifecycle config, Config rule results
Management account Organization CloudTrail, all Regions Centralised root access Identity Center, MFA every sign-in Every member account Config recorder, service-linked role EBS encryption, S3 Block Public Access Backup plan with vault lock Audit (delegated admin) GuardDuty org configuration Security Hub CSPM central policy Config pack and aggregator Log archive account CloudTrail bucket: KMS, Object Lock, versioning Config bucket for snapshots and history Evidence for the auditor Config advanced queries, CSV or JSON Security Hub findings, validate-logs Not in Terraform Identity Center MFA mode (console), policies, access reviews, training and the auditor's sampling. A conformance pack is detective: it reports drift, people still fix it and keep records.
How the four account roles in this guide fit together. Service names and the missing SOC 2 pack come from the AWS Config, CloudTrail, GuardDuty and Audit Manager documentation linked in each step.

Step 1: Pin versions, configure providers and delegate administration

Run Terraform from the management account with one provider alias per account you touch. The aliases assume the OrganizationAccountAccessRole that Organizations creates in accounts it vends; swap in your own role if you use Control Tower or SSO-based roles.

# versions.tf
terraform {
  required_version = ">= 1.10.0" # validated with CLI 1.12.0; 1.17.1 is current
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.71" # 6.71.0 released 28 October 2026
    }
  }
}

variable "home_region" {
  type    = string
  default = "eu-west-1"
}
variable "org_id" { type = string }                 # o-xxxxxxxxxx
variable "org_root_id" { type = string }            # r-xxxx
variable "log_archive_account_id" { type = string } # dedicated log account
variable "audit_account_id" { type = string }       # delegated admin for security tools
variable "member_account_ids" { type = list(string) }

# Default provider: the Organizations management account.
provider "aws" {
  region = var.home_region
}

provider "aws" {
  alias  = "log_archive"
  region = var.home_region
  assume_role {
    role_arn = "arn:aws:iam::${var.log_archive_account_id}:role/OrganizationAccountAccessRole"
  }
}

provider "aws" {
  alias  = "audit"
  region = var.home_region
  assume_role {
    role_arn = "arn:aws:iam::${var.audit_account_id}:role/OrganizationAccountAccessRole"
  }
}

data "aws_caller_identity" "mgmt" {}

Trusted access must be on before delegation works. If your aws_organizations_organization is already managed in Terraform, add these to aws_service_access_principals instead.

# Run once in the management account (AWS CLI v2).
for p in cloudtrail.amazonaws.com config.amazonaws.com \
         config-multiaccountsetup.amazonaws.com guardduty.amazonaws.com \
         securityhub.amazonaws.com iam.amazonaws.com; do
  aws organizations enable-aws-service-access --service-principal "$p"
done
aws organizations list-aws-service-access-for-organization \
  --query 'EnabledServicePrincipals[].ServicePrincipal'

Now make the audit account the delegated administrator, and turn on centralised root access so member accounts stop having usable root passwords:

# file: delegation.tf
# Step 1: make the audit account the delegated administrator.
resource "aws_guardduty_organization_admin_account" "audit" {
  admin_account_id = var.audit_account_id
}

resource "aws_securityhub_organization_admin_account" "audit" {
  admin_account_id = var.audit_account_id
}

# Config needs two registrations: one for aggregators, one for org rules and packs.
resource "aws_organizations_delegated_administrator" "config" {
  account_id        = var.audit_account_id
  service_principal = "config.amazonaws.com"
}

resource "aws_organizations_delegated_administrator" "config_multiaccount" {
  account_id        = var.audit_account_id
  service_principal = "config-multiaccountsetup.amazonaws.com"
}

# Centralised root access: remove long-lived root credentials from member accounts.
resource "aws_iam_organizations_features" "root" {
  enabled_features = ["RootCredentialsManagement", "RootSessions"]
}

GuardDuty delegation is Regional: the GuardDuty Organizations docs say the admin account is enabled "only in the current AWS Region", and you must use the same admin account in every Region. Repeat the GuardDuty and Security Hub blocks per Region with a provider per Region.

Step 2: Build the log archive bucket and KMS key

The auditor will ask two things about logs: can anyone alter them, and do they cover the whole observation window. Object Lock answers the first, a lifecycle longer than your window answers the second. I use 400 days so a 12-month window plus fieldwork is covered. The bucket and key policies follow the CloudTrail S3 bucket policy for organization trails and the CloudTrail KMS key policy pages, including the aws:SourceArn conditions AWS recommends.

# file: archive.tf
# Step 2: log archive bucket and KMS key (log archive account).
locals {
  trail_name   = "org-trail"
  trail_arn    = "arn:aws:cloudtrail:${var.home_region}:${data.aws_caller_identity.mgmt.account_id}:trail/${local.trail_name}"
  trail_bucket = "org-cloudtrail-${var.log_archive_account_id}"
  mgmt_id      = data.aws_caller_identity.mgmt.account_id
}

data "aws_iam_policy_document" "trail_key" {
  statement {
    sid       = "EnableRootAdmin"
    actions   = ["kms:*"]
    resources = ["*"]
    principals {
      type        = "AWS"
      identifiers = ["arn:aws:iam::${var.log_archive_account_id}:root"]
    }
  }
  statement {
    sid       = "AllowCloudTrailEncryptLogs"
    actions   = ["kms:GenerateDataKey*"]
    resources = ["*"]
    principals {
      type        = "Service"
      identifiers = ["cloudtrail.amazonaws.com"]
    }
    condition {
      test     = "StringEquals"
      variable = "aws:SourceArn"
      values   = [local.trail_arn]
    }
    condition {
      test     = "StringLike"
      variable = "kms:EncryptionContext:aws:cloudtrail:arn"
      values   = ["arn:aws:cloudtrail:*:${local.mgmt_id}:trail/*"]
    }
  }
  statement {
    sid       = "AllowCloudTrailDescribeKey"
    actions   = ["kms:DescribeKey"]
    resources = ["*"]
    principals {
      type        = "Service"
      identifiers = ["cloudtrail.amazonaws.com"]
    }
    condition {
      test     = "StringEquals"
      variable = "aws:SourceArn"
      values   = [local.trail_arn]
    }
  }
  statement {
    sid       = "AllowAuditAccountToReadLogs"
    actions   = ["kms:Decrypt"]
    resources = ["*"]
    principals {
      type        = "AWS"
      identifiers = ["arn:aws:iam::${var.audit_account_id}:root"]
    }
    condition {
      test     = "Null"
      variable = "kms:EncryptionContext:aws:cloudtrail:arn"
      values   = ["false"]
    }
  }
}

resource "aws_kms_key" "trail" {
  provider                = aws.log_archive
  description             = "CloudTrail organization trail"
  enable_key_rotation     = true # Security Hub control KMS.4
  deletion_window_in_days = 30
  policy                  = data.aws_iam_policy_document.trail_key.json
}

resource "aws_s3_bucket" "trail" {
  provider            = aws.log_archive
  bucket              = local.trail_bucket
  object_lock_enabled = true # can only be set at creation time
}

resource "aws_s3_bucket_versioning" "trail" {
  provider = aws.log_archive
  bucket   = aws_s3_bucket.trail.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_object_lock_configuration" "trail" {
  provider = aws.log_archive
  bucket   = aws_s3_bucket.trail.id
  rule {
    default_retention {
      mode = "GOVERNANCE" # COMPLIANCE cannot be shortened by anyone, including root
      days = 400
    }
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "trail" {
  provider = aws.log_archive
  bucket   = aws_s3_bucket.trail.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = "aws:kms"
      kms_master_key_id = aws_kms_key.trail.arn
    }
    bucket_key_enabled = true
  }
}

resource "aws_s3_bucket_public_access_block" "trail" {
  provider                = aws.log_archive
  bucket                  = aws_s3_bucket.trail.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_lifecycle_configuration" "trail" {
  provider = aws.log_archive
  bucket   = aws_s3_bucket.trail.id
  rule {
    id     = "retain-400-days"
    status = "Enabled"
    filter {}
    transition {
      days          = 90
      storage_class = "GLACIER_IR"
    }
    expiration {
      days = 400
    }
    noncurrent_version_expiration {
      noncurrent_days = 30
    }
  }
}

data "aws_iam_policy_document" "trail_bucket" {
  statement {
    sid       = "AWSCloudTrailAclCheck20150319"
    actions   = ["s3:GetBucketAcl"]
    resources = [aws_s3_bucket.trail.arn]
    principals {
      type        = "Service"
      identifiers = ["cloudtrail.amazonaws.com"]
    }
    condition {
      test     = "StringEquals"
      variable = "aws:SourceArn"
      values   = [local.trail_arn]
    }
  }
  statement {
    sid       = "AWSCloudTrailWrite20150319"
    actions   = ["s3:PutObject"]
    resources = ["${aws_s3_bucket.trail.arn}/AWSLogs/${local.mgmt_id}/*"]
    principals {
      type        = "Service"
      identifiers = ["cloudtrail.amazonaws.com"]
    }
    condition {
      test     = "StringEquals"
      variable = "s3:x-amz-acl"
      values   = ["bucket-owner-full-control"]
    }
    condition {
      test     = "StringEquals"
      variable = "aws:SourceArn"
      values   = [local.trail_arn]
    }
  }
  statement {
    sid       = "AWSCloudTrailOrganizationWrite20150319"
    actions   = ["s3:PutObject"]
    resources = ["${aws_s3_bucket.trail.arn}/AWSLogs/${var.org_id}/*"]
    principals {
      type        = "Service"
      identifiers = ["cloudtrail.amazonaws.com"]
    }
    condition {
      test     = "StringEquals"
      variable = "s3:x-amz-acl"
      values   = ["bucket-owner-full-control"]
    }
    condition {
      test     = "StringEquals"
      variable = "aws:SourceArn"
      values   = [local.trail_arn]
    }
  }
  statement {
    sid       = "DenyInsecureTransport" # Security Hub control S3.5
    effect    = "Deny"
    actions   = ["s3:*"]
    resources = [aws_s3_bucket.trail.arn, "${aws_s3_bucket.trail.arn}/*"]
    principals {
      type        = "*"
      identifiers = ["*"]
    }
    condition {
      test     = "Bool"
      variable = "aws:SecureTransport"
      values   = ["false"]
    }
  }
}

resource "aws_s3_bucket_policy" "trail" {
  provider = aws.log_archive
  bucket   = aws_s3_bucket.trail.id
  policy   = data.aws_iam_policy_document.trail_bucket.json
}

GOVERNANCE mode lets a principal with s3:BypassGovernanceRetention delete early, which is what you want while you are still learning. Move to COMPLIANCE once retention is settled, knowing that nobody, root included, can shorten it afterwards. Security Hub CSPM control CloudTrail.7 also wants server access logging on this bucket; point it at a second bucket in the same account.

Step 3: Create the organization CloudTrail

The trail lives in the management account and copies itself into every member account. Log file validation writes hourly digest files, which is how you prove later that no log was edited.

# file: trail.tf
# Step 3: organization trail.
# The organization trail lives in the management account.
resource "aws_cloudtrail" "org" {
  name                          = local.trail_name
  s3_bucket_name                = aws_s3_bucket.trail.id
  kms_key_id                    = aws_kms_key.trail.arn
  is_organization_trail         = true
  is_multi_region_trail         = true
  include_global_service_events = true
  enable_log_file_validation    = true # digest files prove logs were not altered

  depends_on = [aws_s3_bucket_policy.trail]
}
terraform apply -target=aws_s3_bucket_policy.trail -target=aws_cloudtrail.org
aws cloudtrail get-trail-status --name org-trail --query 'IsLogging'
# Expected: true

AWS notes an organization trail can take up to 24 hours to appear in all member accounts and Regions.

Step 4: Record configuration with AWS Config and deploy a SOC 2 conformance pack

Config is the evidence engine: it records every resource's configuration over time, and rules turn that history into compliant or non-compliant. First, a delivery bucket that accepts writes only from your own accounts, using the AWS Config bucket policy statements:

# file: config.tf
# Step 4a: AWS Config delivery bucket in the log archive account.
locals {
  config_bucket   = "org-config-${var.log_archive_account_id}"
  all_account_ids = distinct(concat([local.mgmt_id, var.log_archive_account_id, var.audit_account_id], var.member_account_ids))
}

resource "aws_s3_bucket" "config" {
  provider = aws.log_archive
  bucket   = local.config_bucket
}

resource "aws_s3_bucket_public_access_block" "config" {
  provider                = aws.log_archive
  bucket                  = aws_s3_bucket.config.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

data "aws_iam_policy_document" "config_bucket" {
  statement {
    sid       = "AWSConfigBucketPermissionsCheck"
    actions   = ["s3:GetBucketAcl", "s3:ListBucket"]
    resources = [aws_s3_bucket.config.arn]
    principals {
      type        = "Service"
      identifiers = ["config.amazonaws.com"]
    }
    condition {
      test     = "StringEquals"
      variable = "AWS:SourceAccount"
      values   = local.all_account_ids
    }
  }
  statement {
    sid       = "AWSConfigBucketDelivery"
    actions   = ["s3:PutObject"]
    resources = [for id in local.all_account_ids : "${aws_s3_bucket.config.arn}/AWSLogs/${id}/Config/*"]
    principals {
      type        = "Service"
      identifiers = ["config.amazonaws.com"]
    }
    condition {
      test     = "StringEquals"
      variable = "s3:x-amz-acl"
      values   = ["bucket-owner-full-control"]
    }
    condition {
      test     = "StringEquals"
      variable = "AWS:SourceAccount"
      values   = local.all_account_ids
    }
  }
}

resource "aws_s3_bucket_policy" "config" {
  provider = aws.log_archive
  bucket   = aws_s3_bucket.config.id
  policy   = data.aws_iam_policy_document.config_bucket.json
}

Every account and Region needs a recorder. Terraform has no clean way to loop providers over accounts, so call the module once per account and Region (Terragrunt, a pipeline matrix, or CloudFormation StackSets if you prefer). Record global resources such as IAM in only one Region per account, or you pay to record them repeatedly.

# modules/account-baseline/main.tf
terraform {
  required_providers {
    aws = { source = "hashicorp/aws" }
  }
}

variable "config_bucket" { type = string }
variable "record_global_resources" {
  type    = bool
  default = false
}

# Security Hub control Config.1 wants the service-linked role.
resource "aws_iam_service_linked_role" "config" {
  aws_service_name = "config.amazonaws.com"
}

resource "aws_config_configuration_recorder" "this" {
  name     = "default"
  role_arn = aws_iam_service_linked_role.config.arn
  recording_group {
    all_supported                 = true
    include_global_resource_types = var.record_global_resources
  }
  recording_mode {
    recording_frequency = "CONTINUOUS"
  }
}

resource "aws_config_delivery_channel" "this" {
  name           = "default"
  s3_bucket_name = var.config_bucket
  depends_on     = [aws_config_configuration_recorder.this]
}

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

# Step 8: encryption and public-access defaults (per account, per Region for EBS).
resource "aws_ebs_encryption_by_default" "this" {
  enabled = true
}

resource "aws_s3_account_public_access_block" "this" {
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

Then the conformance pack and an organization aggregator, both in the audit account:

# file: config_org.tf
# Step 4b: per-account baseline, org conformance pack, aggregator.
# One module call per account (and per Region you use). Shown for the audit account.
module "baseline_audit" {
  source                  = "./modules/account-baseline"
  providers               = { aws = aws.audit }
  config_bucket           = aws_s3_bucket.config.id
  record_global_resources = true # only in ONE Region per account
}

resource "aws_config_organization_conformance_pack" "soc2" {
  provider      = aws.audit
  name          = "soc2-aws-baseline"
  template_body = file("${path.module}/soc2-aws-baseline.yaml")

  depends_on = [
    aws_organizations_delegated_administrator.config_multiaccount,
    module.baseline_audit,
  ]
}

# Org-wide aggregator in the audit account: one place to query evidence.
data "aws_iam_policy_document" "config_assume" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["config.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "aggregator" {
  provider           = aws.audit
  name               = "config-org-aggregator"
  assume_role_policy = data.aws_iam_policy_document.config_assume.json
}

resource "aws_iam_role_policy_attachment" "aggregator" {
  provider   = aws.audit
  role       = aws_iam_role.aggregator.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSConfigRoleForOrganizations"
}

resource "aws_config_configuration_aggregator" "org" {
  provider = aws.audit
  name     = "org-aggregator"
  organization_aggregation_source {
    all_regions = true
    role_arn    = aws_iam_role.aggregator.arn
  }
  depends_on = [aws_organizations_delegated_administrator.config]
}

Because AWS ships no SOC 2 pack, the template below is my own mapping of managed rules to common criteria. Every identifier comes from AWS's published NIST, CIS and HIPAA packs in the awslabs/aws-config-rules repository, and MinRetentionTime accepts the values listed on the cw-loggroup-retention-period-check page. Treat the CC labels as a starting point to agree with your auditor.

# file: soc2-aws-baseline.yaml
# Custom pack: AWS publishes no SOC 2 template, so map managed rules yourself.
# Comments show the SOC 2 common criteria each rule supports as evidence.
Resources:
  MultiRegionCloudTrailEnabled:        # CC7.2 monitoring
    Type: AWS::Config::ConfigRule
    Properties:
      ConfigRuleName: multi-region-cloudtrail-enabled
      Source: { Owner: AWS, SourceIdentifier: MULTI_REGION_CLOUD_TRAIL_ENABLED }
  CloudTrailLogFileValidation:         # CC7.2, log integrity
    Type: AWS::Config::ConfigRule
    Properties:
      ConfigRuleName: cloud-trail-log-file-validation-enabled
      Source: { Owner: AWS, SourceIdentifier: CLOUD_TRAIL_LOG_FILE_VALIDATION_ENABLED }
  RootAccountMfaEnabled:               # CC6.1 logical access
    Type: AWS::Config::ConfigRule
    Properties:
      ConfigRuleName: root-account-mfa-enabled
      Source: { Owner: AWS, SourceIdentifier: ROOT_ACCOUNT_MFA_ENABLED }
  IamRootAccessKeyCheck:               # CC6.1
    Type: AWS::Config::ConfigRule
    Properties:
      ConfigRuleName: iam-root-access-key-check
      Source: { Owner: AWS, SourceIdentifier: IAM_ROOT_ACCESS_KEY_CHECK }
  MfaForConsoleUsers:                  # CC6.1
    Type: AWS::Config::ConfigRule
    Properties:
      ConfigRuleName: mfa-enabled-for-iam-console-access
      Source: { Owner: AWS, SourceIdentifier: MFA_ENABLED_FOR_IAM_CONSOLE_ACCESS }
  AccessKeysRotated:                   # CC6.1
    Type: AWS::Config::ConfigRule
    Properties:
      ConfigRuleName: access-keys-rotated
      InputParameters: { maxAccessKeyAge: '90' }
      Source: { Owner: AWS, SourceIdentifier: ACCESS_KEYS_ROTATED }
  EbsEncryptionByDefault:              # CC6.1, data at rest
    Type: AWS::Config::ConfigRule
    Properties:
      ConfigRuleName: ec2-ebs-encryption-by-default
      Source: { Owner: AWS, SourceIdentifier: EC2_EBS_ENCRYPTION_BY_DEFAULT }
  RdsStorageEncrypted:                 # CC6.1
    Type: AWS::Config::ConfigRule
    Properties:
      ConfigRuleName: rds-storage-encrypted
      Source: { Owner: AWS, SourceIdentifier: RDS_STORAGE_ENCRYPTED }
  S3AccountPublicAccessBlocks:         # CC6.6 boundary protection
    Type: AWS::Config::ConfigRule
    Properties:
      ConfigRuleName: s3-account-level-public-access-blocks-periodic
      Source: { Owner: AWS, SourceIdentifier: S3_ACCOUNT_LEVEL_PUBLIC_ACCESS_BLOCKS_PERIODIC }
  GuardDutyEnabled:                    # CC7.2 detection
    Type: AWS::Config::ConfigRule
    Properties:
      ConfigRuleName: guardduty-enabled-centralized
      Source: { Owner: AWS, SourceIdentifier: GUARDDUTY_ENABLED_CENTRALIZED }
  DbInstanceBackupEnabled:             # A1.2 availability, if in scope
    Type: AWS::Config::ConfigRule
    Properties:
      ConfigRuleName: db-instance-backup-enabled
      Source: { Owner: AWS, SourceIdentifier: DB_INSTANCE_BACKUP_ENABLED }
  LogGroupRetention:                   # CC7.2, keep logs past the audit window
    Type: AWS::Config::ConfigRule
    Properties:
      ConfigRuleName: cw-loggroup-retention-period-check
      InputParameters: { MinRetentionTime: '400' }
      Source: { Owner: AWS, SourceIdentifier: CW_LOGGROUP_RETENTION_PERIOD_CHECK }

Check the rollout:

aws configservice get-organization-conformance-pack-detailed-status \
  --organization-conformance-pack-name soc2-aws-baseline \
  --query 'OrganizationConformancePackDetailedStatuses[].[AccountId,Status]' \
  --output table
# Expected: CREATE_SUCCESSFUL for every account that has a recorder

Step 5: Turn on GuardDuty for every account

ALL enables GuardDuty in existing and new members. The datasources block is deprecated, so features are separate resources. Protection plans cost extra per volume of data analysed, so enable them knowingly.

# file: guardduty.tf
# Step 5: GuardDuty, run from the audit (delegated admin) account.
# Delegation creates a detector in the admin account; read it rather than create one.
data "aws_guardduty_detector" "audit" {
  provider   = aws.audit
  depends_on = [aws_guardduty_organization_admin_account.audit]
}

resource "aws_guardduty_organization_configuration" "org" {
  provider                         = aws.audit
  detector_id                      = data.aws_guardduty_detector.audit.id
  auto_enable_organization_members = "ALL" # existing and new accounts
}

resource "aws_guardduty_organization_configuration_feature" "s3" {
  provider    = aws.audit
  detector_id = data.aws_guardduty_detector.audit.id
  name        = "S3_DATA_EVENTS"
  auto_enable = "ALL"
}

resource "aws_guardduty_organization_configuration_feature" "rds" {
  provider    = aws.audit
  detector_id = data.aws_guardduty_detector.audit.id
  name        = "RDS_LOGIN_EVENTS"
  auto_enable = "ALL"
}

resource "aws_guardduty_organization_configuration_feature" "malware" {
  provider    = aws.audit
  detector_id = data.aws_guardduty_detector.audit.id
  name        = "EBS_MALWARE_PROTECTION"
  auto_enable = "ALL"
}

Step 6: Enable Security Hub CSPM with central configuration

The original product is now called Security Hub CSPM, and it runs alongside the new Security Hub, which reached general availability in December 2025 and correlates CSPM findings with GuardDuty and Inspector; the Terraform resources still use the securityhub prefix. Central configuration pushes one policy to the whole organisation. The CIS standard page lists v5.0.0 as supported and recommended, with the ARN used below.

# file: securityhub.tf
# Step 6: Security Hub CSPM central configuration.
resource "aws_securityhub_finding_aggregator" "org" {
  provider     = aws.audit
  linking_mode = "ALL_REGIONS"
  depends_on   = [aws_securityhub_organization_admin_account.audit]
}

resource "aws_securityhub_organization_configuration" "org" {
  provider              = aws.audit
  auto_enable           = false
  auto_enable_standards = "NONE"
  organization_configuration {
    configuration_type = "CENTRAL"
  }
  depends_on = [aws_securityhub_finding_aggregator.org]
}

resource "aws_securityhub_configuration_policy" "soc2" {
  provider    = aws.audit
  name        = "soc2-baseline"
  description = "FSBP plus CIS AWS Foundations v5.0.0"
  configuration_policy {
    service_enabled = true
    enabled_standard_arns = [
      "arn:aws:securityhub:${var.home_region}::standards/aws-foundational-security-best-practices/v/1.0.0",
      "arn:aws:securityhub:${var.home_region}::standards/cis-aws-foundations-benchmark/v/5.0.0",
    ]
    security_controls_configuration {
      disabled_control_identifiers = [] # document every exception you add here
    }
  }
  depends_on = [aws_securityhub_organization_configuration.org]
}

resource "aws_securityhub_configuration_policy_association" "root" {
  provider  = aws.audit
  target_id = var.org_root_id
  policy_id = aws_securityhub_configuration_policy.soc2.id
}

Expect a backlog of findings on day one. That is normal; what auditors look for is that someone triages them on a schedule and that exceptions, the disabled_control_identifiers list, have a written reason.

Step 7: Enforce MFA and short sessions in IAM Identity Center

The MFA prompt mode is a console setting. The Identity Center MFA docs describe it under Settings, Authentication, with "Every time they sign in (always-on)" as the default and context-aware as the weaker alternative. Leave it on always-on, screenshot it for the evidence folder, and note that if you federate from an external IdP, MFA is enforced there instead. Permission sets, by contrast, are Terraform:

# file: access.tf
# Step 7: Identity Center permission sets with short sessions.
# MFA mode itself is a console setting (Settings > Authentication).
data "aws_ssoadmin_instances" "this" {}

resource "aws_ssoadmin_permission_set" "readonly" {
  name             = "ReadOnly"
  instance_arn     = tolist(data.aws_ssoadmin_instances.this.arns)[0]
  session_duration = "PT8H"
}

resource "aws_ssoadmin_managed_policy_attachment" "readonly" {
  instance_arn       = aws_ssoadmin_permission_set.readonly.instance_arn
  permission_set_arn = aws_ssoadmin_permission_set.readonly.arn
  managed_policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}

resource "aws_ssoadmin_permission_set" "admin" {
  name             = "AdministratorAccess"
  instance_arn     = tolist(data.aws_ssoadmin_instances.this.arns)[0]
  session_duration = "PT1H" # short sessions for privileged access
}

resource "aws_ssoadmin_managed_policy_attachment" "admin" {
  instance_arn       = aws_ssoadmin_permission_set.admin.instance_arn
  permission_set_arn = aws_ssoadmin_permission_set.admin.arn
  managed_policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
}

Step 8: Make encryption and public-access blocks the default

The baseline module in Step 4 already sets aws_ebs_encryption_by_default (per Region) and aws_s3_account_public_access_block. S3 itself has encrypted every new object with SSE-S3 since 5 January 2023, so the remaining gaps are usually RDS instances created with storage_encrypted = false and old unencrypted volumes. The rds-storage-encrypted and ec2-ebs-encryption-by-default rules in the pack will list them.

Step 9: Back up with AWS Backup and lock the vault

A backup control is only evidence if it runs and someone can restore from it. Tag-based selection keeps new databases covered without editing the plan.

# file: backup.tf
# Step 9: AWS Backup in a workload account (shown with the audit provider for brevity).
resource "aws_backup_vault" "main" {
  provider = aws.audit
  name     = "soc2-main"
}

resource "aws_backup_vault_lock_configuration" "main" {
  provider           = aws.audit
  backup_vault_name  = aws_backup_vault.main.name
  min_retention_days = 7
  max_retention_days = 400
  # No changeable_for_days: governance mode. Adding it creates compliance mode,
  # which becomes immutable once that many days pass.
}

resource "aws_backup_plan" "daily" {
  provider = aws.audit
  name     = "daily-35d"
  rule {
    rule_name         = "daily"
    target_vault_name = aws_backup_vault.main.name
    schedule          = "cron(0 3 * * ? *)" # 03:00 UTC
    lifecycle {
      delete_after = 35
    }
  }
}

data "aws_iam_policy_document" "backup_assume" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["backup.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "backup" {
  provider           = aws.audit
  name               = "soc2-backup"
  assume_role_policy = data.aws_iam_policy_document.backup_assume.json
}

resource "aws_iam_role_policy_attachment" "backup" {
  provider   = aws.audit
  role       = aws_iam_role.backup.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSBackupServiceRolePolicyForBackup"
}

resource "aws_backup_selection" "tagged" {
  provider     = aws.audit
  name         = "backup-daily-tag"
  plan_id      = aws_backup_plan.daily.id
  iam_role_arn = aws_iam_role.backup.arn
  selection_tag {
    type  = "STRINGEQUALS"
    key   = "backup"
    value = "daily"
  }
}

Then run and record a restore at least once per window, for example with aws backup start-restore-job, and keep the job ID and the check you ran against the restored data.

Step 10: Set log retention and export evidence

Log groups default to never expiring, which the rule treats as compliant, but explicit retention keeps costs predictable:

# file: logs.tf
# Step 10: log retention that outlives the audit window.
resource "aws_cloudwatch_log_group" "app" {
  provider          = aws.audit
  name              = "/app/api"
  retention_in_days = 400 # one of the values CloudWatch accepts
}

Evidence is mostly queries you can rerun. The Audit Manager page itself recommends Config advanced queries and get-resource-config-history for exports:

# Org-wide: every EBS volume and whether it is encrypted (run in the audit account)
aws configservice select-aggregate-resource-config \
  --configuration-aggregator-name org-aggregator \
  --expression "SELECT accountId, resourceId, configuration.encrypted WHERE resourceType = 'AWS::EC2::Volume'" \
  --output json > ebs-volumes.json

# History of one resource across the window
aws configservice get-resource-config-history \
  --resource-type AWS::S3::Bucket --resource-id org-cloudtrail-111122223333 \
  --earlier-time 2026-07-01T00:00:00Z --later-time 2026-09-30T23:59:59Z > bucket-history.json

# Prove CloudTrail logs were not altered during the window
aws cloudtrail validate-logs \
  --trail-arn arn:aws:cloudtrail:eu-west-1:111122223333:trail/org-trail \
  --start-time 2026-07-01T00:00:00Z --end-time 2026-09-30T23:59:59Z

# Failed Security Hub CSPM controls, for the triage record
aws securityhub get-findings \
  --filters '{"ComplianceStatus":[{"Value":"FAILED","Comparison":"EQUALS"}],"RecordState":[{"Value":"ACTIVE","Comparison":"EQUALS"}]}' \
  --max-items 500 > failed-controls.json

If you use Vanta, Drata or Secureframe, they read most of this through their AWS integration; the commands are still how you answer an auditor's specific sample request. For a second opinion on the same accounts, Prowler maps its checks to SOC 2 as well.

Troubleshooting

Symptom Likely cause Fix
InsufficientS3BucketPolicyException creating the trail Bucket policy missing the AWSLogs/o-xxxx/* statement or wrong aws:SourceArn Use the three-statement org policy; the trail ARN must use the management account ID
Trail logs stop with KMS errors Key policy lacks kms:GenerateDataKey* for cloudtrail.amazonaws.com or the encryption context pattern Add the encrypt and DescribeKey statements from Step 2
Conformance pack CREATE_FAILED in some accounts No configuration recorder in that account or Region Apply the baseline module there, then re-apply the pack
Service role name AWSServiceRoleForConfig has been taken The role was already created by the console or Control Tower terraform import it, or reference it with a data source
MaxNumberOfConfigurationRecordersExceededException A recorder already exists (one per Region) Import the existing default recorder instead of creating one
GuardDuty "detector already exists" Delegation auto-created the admin detector Use the aws_guardduty_detector data source as shown
Security Hub policy has no effect Central configuration not active or no association Check configuration_type = "CENTRAL", the finding aggregator, and the root association
Duplicate IAM findings from every Region Global resources recorded in all Regions Set record_global_resources = true in one Region only
Vault lock cannot be removed changeable_for_days set, so compliance mode is locked Expected behaviour; test lock settings in a sandbox account first

FAQ

Is there an AWS Config conformance pack for SOC 2?

No. AWS's own Audit Manager migration page lists SOC 2 with no equivalent pack, so you build a custom pack from managed rules, as in Step 4, or use a compliance platform's mapping. Conformance packs only cover technical controls in any case.

Can I still use AWS Audit Manager for SOC 2?

Only if your account already had it set up. Audit Manager stopped accepting new customers on 30 April 2026 and is in maintenance mode, with no new frameworks or Regions. New programmes should use Config, Security Hub CSPM and CloudTrail directly, or a partner platform.

How long should I keep logs for SOC 2?

SOC 2 sets no fixed number. Keep logs for at least the observation window plus the time until fieldwork, and longer if a contract demands it. Twelve months plus a buffer, 400 days here, is a common choice and lines up with a valid CloudWatch retention value.

Do I need Security Hub CSPM if I already have AWS Config?

They overlap, since Security Hub CSPM controls run on Config service-linked rules, but Security Hub adds severities, the CIS v5.0.0 and FSBP standards, and one findings view across accounts. For a small team that triage view is usually what makes the monitoring control credible.

When I take over an AWS organisation heading into SOC 2, I check three things before writing any Terraform: whether CloudTrail already covers every account and Region with validation on, whether any human still signs in as an IAM user or root, and whether a restore has ever been tested. Those gaps are the ones auditors find first. Official references: CloudTrail organization trails, Config conformance packs, GuardDuty with Organizations, Security Hub CSPM standards and Identity Center MFA. For state handling on a configuration this sensitive, see Terraform State in 2026.

Related: AWS IAM Explained (2026): Users vs Roles, 9 Policy Types, SCPs and RCPs, How to Test Terraform: terraform test, TFLint and Checkov (2026) and How to Use GitHub Actions OIDC With AWS, GCP and Azure (2026).

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related