How to Implement SOC 2 Controls on AWS with Terraform (2026)
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 |
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
How to Fix AWS AccessDenied and 'Not Authorized to Perform' Errors (2026)
Fix AWS AccessDenied and is not authorized to perform errors step by step: read the 2026 message format with policy ARNs, confirm identity with get-caller-identity, decode encoded messages, simulate policies, search CloudTrail, and clear SCP, RCP, boundary, KMS and S3 denials.
15 minOct 31, 2026How to Fix SSH Permission Denied (publickey): Causes and Fixes (2026)
Fix SSH 'Permission denied (publickey)' step by step: read ssh -vvv, use the right user (ec2-user, ubuntu, admin, git), fix key and authorized_keys permissions, IdentitiesOnly and agent issues, sshd -T and Match blocks, ssh-rsa and DSA errors, SELinux, and EC2, GCP and Azure keys.
15 minOct 31, 2026How to Audit an AWS Account with Prowler 5.47: Step-by-Step (2026)
Audit an AWS account with Prowler 5.47.0: install a pinned version, deploy the read-only ProwlerScan role, run CIS 6.0 and SOC 2 checks, read the OCSF output, fix and mute findings, schedule weekly scans in GitHub Actions, and scan a whole organisation.
11 min