· PathShield Team · Tutorials · 12 min read
How to Prepare Your AWS for a SOC 2 Audit in 2025
Complete guide to SOC 2 compliance on AWS. Learn what auditors look for, common failures, and get practical scripts to automate evidence collection.
How to Prepare Your AWS for a SOC 2 Audit in 2025
Your biggest customer just asked for your SOC 2 report. Your investors are requiring it for the next round. You have 90 days. Don’t panic—this guide walks you through exactly what you need to do to pass your SOC 2 audit on AWS, including scripts to automate evidence collection.
What SOC 2 Actually Means for AWS
SOC 2 isn’t about checking boxes—it’s about proving you have controls in place to protect customer data. For AWS environments, auditors focus on five trust services criteria:
- Security: Are resources protected against unauthorized access?
- Availability: Can you maintain uptime commitments?
- Processing Integrity: Does your system process data correctly?
- Confidentiality: Is sensitive data encrypted and access-controlled?
- Privacy: Do you handle personal data according to your privacy notice?
Most startups go for Type I (point-in-time) first, then Type II (over a period) later.
The Pre-Audit Reality Check
What Auditors Will Actually Look For
Based on hundreds of startup audits, here’s what auditors spend 80% of their time on:
- Access Management: Who can access what and why
- Change Management: How changes are approved and tracked
- Logging & Monitoring: Can you detect and respond to incidents
- Data Protection: Encryption at rest and in transit
- Incident Response: Documented procedures and evidence
Common Failure Points
- No MFA on admin accounts (instant red flag)
- Public S3 buckets or databases
- No CloudTrail logging
- Shared root account credentials
- No documented procedures
- Missing evidence of regular reviews
Your 90-Day SOC 2 Preparation Plan
Days 1-30: Foundation & Quick Wins
Week 1: Access Control Lockdown
Enable MFA Everywhere
# List users without MFA
aws iam list-users --query 'Users[*].UserName' --output text | while read user; do
mfa_devices=$(aws iam list-mfa-devices --user-name $user --query 'MFADevices[*].SerialNumber' --output text)
if [ -z "$mfa_devices" ]; then
echo "NO MFA: $user"
fi
done
# Create policy requiring MFA
cat > require-mfa-policy.json << EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Deny",
"Action": "*",
"Resource": "*",
"Condition": {
"BoolIfExists": {
"aws:MultiFactorAuthPresent": "false"
}
}
}
]
}
EOF
aws iam create-policy --policy-name RequireMFA --policy-document file://require-mfa-policy.json
Implement Role-Based Access Control
# audit_iam_permissions.py
import boto3
import csv
from datetime import datetime
iam = boto3.client('iam')
def audit_user_permissions():
"""Generate user access report for SOC 2 evidence"""
report = []
# Get all users
users = iam.list_users()['Users']
for user in users:
user_name = user['UserName']
# Get user policies
inline_policies = iam.list_user_policies(UserName=user_name)['PolicyNames']
attached_policies = iam.list_attached_user_policies(UserName=user_name)['AttachedPolicies']
# Get user groups
groups = iam.list_groups_for_user(UserName=user_name)['Groups']
# Get access keys
access_keys = iam.list_access_keys(UserName=user_name)['AccessKeyMetadata']
report.append({
'UserName': user_name,
'CreatedDate': user['CreateDate'].strftime('%Y-%m-%d'),
'Groups': ', '.join([g['GroupName'] for g in groups]),
'InlinePolicies': len(inline_policies),
'AttachedPolicies': ', '.join([p['PolicyName'] for p in attached_policies]),
'AccessKeys': len(access_keys),
'MFAEnabled': len(iam.list_mfa_devices(UserName=user_name)['MFADevices']) > 0,
'LastActivity': get_last_activity(user_name)
})
# Save report
with open(f'iam_audit_{datetime.now().strftime("%Y%m%d")}.csv', 'w') as f:
writer = csv.DictWriter(f, fieldnames=report[0].keys())
writer.writeheader()
writer.writerows(report)
return report
Week 2: Logging & Monitoring Setup
Enable Comprehensive CloudTrail
# Create S3 bucket for logs with encryption
aws s3 mb s3://company-soc2-audit-logs-$(date +%s)
aws s3api put-bucket-encryption \
--bucket company-soc2-audit-logs-$(date +%s) \
--server-side-encryption-configuration '{
"Rules": [{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "AES256"
}
}]
}'
# Enable CloudTrail for all regions
aws cloudtrail create-trail \
--name soc2-audit-trail \
--s3-bucket-name company-soc2-audit-logs-$(date +%s) \
--is-multi-region-trail \
--enable-log-file-validation \
--event-selectors '[{
"ReadWriteType": "All",
"IncludeManagementEvents": true,
"DataResources": [{
"Type": "AWS::S3::Object",
"Values": ["arn:aws:s3:::*/*"]
}]
}]'
aws cloudtrail start-logging --name soc2-audit-trail
Set Up Security Monitoring
# cloudwatch_alarms.py
import boto3
import json
cloudwatch = boto3.client('cloudwatch')
sns = boto3.client('sns')
def create_security_alarms():
"""Create CloudWatch alarms for SOC 2 monitoring requirements"""
# Create SNS topic for alerts
topic = sns.create_topic(Name='soc2-security-alerts')
topic_arn = topic['TopicArn']
# Subscribe team to alerts
sns.subscribe(
TopicArn=topic_arn,
Protocol='email',
Endpoint='security@company.com'
)
alarms = [
{
'name': 'RootAccountUsage',
'description': 'Alert on root account usage',
'metric_name': 'RootAccountUsageCount',
'threshold': 1
},
{
'name': 'UnauthorizedAPICalls',
'description': 'Alert on unauthorized API calls',
'metric_name': 'UnauthorizedAPICallsCount',
'threshold': 5
},
{
'name': 'ConsoleLoginWithoutMFA',
'description': 'Alert on console login without MFA',
'metric_name': 'ConsoleLoginWithoutMFACount',
'threshold': 1
},
{
'name': 'IAMPolicyChanges',
'description': 'Alert on IAM policy changes',
'metric_name': 'IAMPolicyChangesCount',
'threshold': 1
}
]
for alarm in alarms:
cloudwatch.put_metric_alarm(
AlarmName=f"SOC2-{alarm['name']}",
AlarmDescription=alarm['description'],
ActionsEnabled=True,
AlarmActions=[topic_arn],
MetricName=alarm['metric_name'],
Namespace='CloudTrailMetrics',
Statistic='Sum',
Dimensions=[],
Period=300,
EvaluationPeriods=1,
Threshold=alarm['threshold'],
ComparisonOperator='GreaterThanOrEqualToThreshold'
)
Week 3: Data Protection Controls
Enforce Encryption Everywhere
# encryption_audit.py
import boto3
from datetime import datetime
def audit_encryption():
"""Audit encryption status for SOC 2 compliance"""
report = {
'timestamp': datetime.now().isoformat(),
'findings': []
}
# Audit S3 encryption
s3 = boto3.client('s3')
buckets = s3.list_buckets()['Buckets']
for bucket in buckets:
bucket_name = bucket['Name']
try:
encryption = s3.get_bucket_encryption(Bucket=bucket_name)
report['findings'].append({
'resource': f's3:{bucket_name}',
'encrypted': True,
'method': encryption['ServerSideEncryptionConfiguration']['Rules'][0]['ApplyServerSideEncryptionByDefault']['SSEAlgorithm']
})
except s3.exceptions.ClientError:
report['findings'].append({
'resource': f's3:{bucket_name}',
'encrypted': False,
'action_required': 'Enable encryption'
})
# Audit RDS encryption
rds = boto3.client('rds')
databases = rds.describe_db_instances()['DBInstances']
for db in databases:
report['findings'].append({
'resource': f"rds:{db['DBInstanceIdentifier']}",
'encrypted': db.get('StorageEncrypted', False),
'action_required': 'Enable encryption' if not db.get('StorageEncrypted') else None
})
# Audit EBS encryption
ec2 = boto3.client('ec2')
volumes = ec2.describe_volumes()['Volumes']
for volume in volumes:
report['findings'].append({
'resource': f"ebs:{volume['VolumeId']}",
'encrypted': volume.get('Encrypted', False),
'action_required': 'Enable encryption' if not volume.get('Encrypted') else None
})
return report
# Enable default EBS encryption
def enable_default_encryption():
ec2 = boto3.client('ec2')
ec2.enable_ebs_encryption_by_default()
Week 4: Network Security
Implement Network Segmentation
# network_security.tf
resource "aws_vpc" "production" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "production-vpc"
Environment = "production"
SOC2 = "true"
}
}
# Public subnet for load balancers only
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.production.id
cidr_block = "10.0.${count.index}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "public-subnet-${count.index + 1}"
Type = "public"
}
}
# Private subnet for applications
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.production.id
cidr_block = "10.0.${count.index + 10}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "private-subnet-${count.index + 1}"
Type = "private"
}
}
# Database subnet (no internet access)
resource "aws_subnet" "database" {
count = 2
vpc_id = aws_vpc.production.id
cidr_block = "10.0.${count.index + 20}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "database-subnet-${count.index + 1}"
Type = "database"
}
}
# Network ACLs for additional security
resource "aws_network_acl_rule" "database_ingress" {
network_acl_id = aws_network_acl.database.id
rule_number = 100
protocol = "tcp"
rule_action = "allow"
cidr_block = "10.0.10.0/23" # Only from private subnets
from_port = 3306
to_port = 3306
}
Days 31-60: Documentation & Procedures
Security Policies You Need
Create these documents (templates provided):
- Information Security Policy
# Information Security Policy
## Purpose
This policy establishes the security requirements for protecting [Company Name]'s information assets.
## Scope
Applies to all employees, contractors, and systems processing company or customer data.
## Policy Statements
### Access Control
- All access follows principle of least privilege
- Multi-factor authentication required for all administrative access
- Access reviews conducted quarterly
- Terminated employee access revoked within 24 hours
### Data Protection
- All data encrypted at rest using AES-256
- All data encrypted in transit using TLS 1.2+
- Production data never used in development
- Data retention follows documented schedule
### Change Management
- All production changes require approval
- Changes tested in staging environment
- Rollback procedures documented
- Post-implementation review required
### Incident Response
- Security incidents reported within 1 hour
- Incident response team activated per runbook
- Root cause analysis within 72 hours
- Lessons learned documented
## Enforcement
Violations may result in disciplinary action up to termination.
Last Updated: [Date]
Approved By: [CTO/CEO Name]
- Incident Response Plan
- Change Management Procedure
- Access Control Matrix
- Risk Assessment
Automate Evidence Collection
# soc2_evidence_collector.py
import boto3
import json
import os
from datetime import datetime, timedelta
import zipfile
class SOC2EvidenceCollector:
def __init__(self):
self.evidence_dir = f"soc2_evidence_{datetime.now().strftime('%Y%m%d')}"
os.makedirs(self.evidence_dir, exist_ok=True)
def collect_all_evidence(self):
"""Collect all evidence for SOC 2 audit"""
print("Collecting SOC 2 evidence...")
# User access evidence
self.collect_user_access_evidence()
# Security configuration evidence
self.collect_security_config_evidence()
# Change management evidence
self.collect_change_evidence()
# Monitoring evidence
self.collect_monitoring_evidence()
# Create evidence package
self.create_evidence_package()
def collect_user_access_evidence(self):
"""Collect evidence of user access controls"""
iam = boto3.client('iam')
# User list with MFA status
users = []
for user in iam.list_users()['Users']:
user_data = {
'UserName': user['UserName'],
'CreateDate': user['CreateDate'].isoformat(),
'MFAEnabled': len(iam.list_mfa_devices(UserName=user['UserName'])['MFADevices']) > 0,
'Groups': [g['GroupName'] for g in iam.list_groups_for_user(UserName=user['UserName'])['Groups']],
'LastPasswordUse': user.get('PasswordLastUsed', 'Never').isoformat() if hasattr(user.get('PasswordLastUsed', 'Never'), 'isoformat') else 'Never'
}
users.append(user_data)
with open(f"{self.evidence_dir}/user_access_report.json", 'w') as f:
json.dump(users, f, indent=2)
def collect_security_config_evidence(self):
"""Collect evidence of security configurations"""
evidence = {
'collection_date': datetime.now().isoformat(),
'configurations': {}
}
# S3 bucket policies
s3 = boto3.client('s3')
buckets = s3.list_buckets()['Buckets']
evidence['configurations']['s3_buckets'] = []
for bucket in buckets:
bucket_name = bucket['Name']
bucket_config = {
'name': bucket_name,
'public_access_blocked': False,
'encrypted': False,
'versioning': False
}
try:
# Check public access block
pab = s3.get_public_access_block(Bucket=bucket_name)
bucket_config['public_access_blocked'] = all([
pab['PublicAccessBlockConfiguration']['BlockPublicAcls'],
pab['PublicAccessBlockConfiguration']['IgnorePublicAcls'],
pab['PublicAccessBlockConfiguration']['BlockPublicPolicy'],
pab['PublicAccessBlockConfiguration']['RestrictPublicBuckets']
])
except:
pass
try:
# Check encryption
enc = s3.get_bucket_encryption(Bucket=bucket_name)
bucket_config['encrypted'] = True
except:
pass
evidence['configurations']['s3_buckets'].append(bucket_config)
# Security groups
ec2 = boto3.client('ec2')
sgs = ec2.describe_security_groups()['SecurityGroups']
evidence['configurations']['security_groups'] = []
for sg in sgs:
open_to_world = any(
rule.get('CidrIp') == '0.0.0.0/0'
for rule in sg.get('IpPermissions', [])
for rule in rule.get('IpRanges', [])
)
evidence['configurations']['security_groups'].append({
'id': sg['GroupId'],
'name': sg['GroupName'],
'open_to_world': open_to_world,
'rules_count': len(sg.get('IpPermissions', []))
})
with open(f"{self.evidence_dir}/security_config_evidence.json", 'w') as f:
json.dump(evidence, f, indent=2)
def collect_change_evidence(self):
"""Collect evidence of change management"""
# CloudTrail events for the audit period
cloudtrail = boto3.client('cloudtrail')
# Get events from last 90 days
end_time = datetime.now()
start_time = end_time - timedelta(days=90)
events = []
paginator = cloudtrail.get_paginator('lookup_events')
for page in paginator.paginate(
StartTime=start_time,
EndTime=end_time,
LookupAttributes=[
{
'AttributeKey': 'EventName',
'AttributeValue': 'CreateUser'
},
]
):
events.extend(page['Events'])
with open(f"{self.evidence_dir}/change_events.json", 'w') as f:
json.dump(events, f, indent=2, default=str)
def collect_monitoring_evidence(self):
"""Collect evidence of monitoring and alerting"""
cloudwatch = boto3.client('cloudwatch')
# List all alarms
alarms = cloudwatch.describe_alarms()['MetricAlarms']
alarm_evidence = []
for alarm in alarms:
alarm_evidence.append({
'name': alarm['AlarmName'],
'state': alarm['StateValue'],
'description': alarm.get('AlarmDescription', ''),
'actions_enabled': alarm['ActionsEnabled'],
'metric': alarm['MetricName']
})
with open(f"{self.evidence_dir}/monitoring_evidence.json", 'w') as f:
json.dump(alarm_evidence, f, indent=2)
def create_evidence_package(self):
"""Create zip file of all evidence"""
zip_name = f"soc2_evidence_{datetime.now().strftime('%Y%m%d')}.zip"
with zipfile.ZipFile(zip_name, 'w') as zipf:
for root, dirs, files in os.walk(self.evidence_dir):
for file in files:
zipf.write(os.path.join(root, file))
print(f"Evidence package created: {zip_name}")
# Run evidence collection
collector = SOC2EvidenceCollector()
collector.collect_all_evidence()
Days 61-90: Testing & Remediation
Pre-Audit Security Assessment
# pre_audit_assessment.py
import boto3
from datetime import datetime
class PreAuditAssessment:
def __init__(self):
self.findings = []
self.critical_count = 0
self.warning_count = 0
def run_assessment(self):
"""Run complete pre-audit assessment"""
print("Running SOC 2 pre-audit assessment...")
# Check all critical controls
self.check_mfa_enforcement()
self.check_root_account_usage()
self.check_public_resources()
self.check_encryption()
self.check_logging()
self.check_backup_procedures()
self.check_access_reviews()
# Generate report
self.generate_report()
def check_mfa_enforcement(self):
"""Check MFA is enforced on all users"""
iam = boto3.client('iam')
users = iam.list_users()['Users']
for user in users:
mfa_devices = iam.list_mfa_devices(UserName=user['UserName'])['MFADevices']
if not mfa_devices:
self.add_finding('CRITICAL', f"User {user['UserName']} does not have MFA enabled")
def check_public_resources(self):
"""Check for publicly accessible resources"""
# Check S3 buckets
s3 = boto3.client('s3')
for bucket in s3.list_buckets()['Buckets']:
try:
acl = s3.get_bucket_acl(Bucket=bucket['Name'])
for grant in acl['Grants']:
if grant['Grantee'].get('Type') == 'Group' and \
'AllUsers' in grant['Grantee'].get('URI', ''):
self.add_finding('CRITICAL', f"S3 bucket {bucket['Name']} is publicly accessible")
except:
pass
# Check RDS instances
rds = boto3.client('rds')
for db in rds.describe_db_instances()['DBInstances']:
if db.get('PubliclyAccessible'):
self.add_finding('CRITICAL', f"RDS instance {db['DBInstanceIdentifier']} is publicly accessible")
def add_finding(self, severity, description):
"""Add finding to report"""
self.findings.append({
'severity': severity,
'description': description,
'timestamp': datetime.now().isoformat()
})
if severity == 'CRITICAL':
self.critical_count += 1
elif severity == 'WARNING':
self.warning_count += 1
def generate_report(self):
"""Generate assessment report"""
report = {
'assessment_date': datetime.now().isoformat(),
'summary': {
'total_findings': len(self.findings),
'critical': self.critical_count,
'warnings': self.warning_count,
'ready_for_audit': self.critical_count == 0
},
'findings': self.findings
}
with open('pre_audit_assessment.json', 'w') as f:
json.dump(report, f, indent=2)
print(f"\nAssessment Complete:")
print(f"Critical Issues: {self.critical_count}")
print(f"Warnings: {self.warning_count}")
print(f"Ready for audit: {'YES' if self.critical_count == 0 else 'NO'}")
During the Audit: What to Expect
Auditor Requests You’ll Get
“Show me your user access matrix”
- Have your IAM audit report ready
- Show quarterly access review evidence
“Demonstrate your change management process”
- Show git commit history
- CloudFormation/Terraform change sets
- Approval workflows
“How do you detect security incidents?”
- Show CloudWatch alarms
- GuardDuty findings
- Incident response runbook
“Prove data is encrypted”
- Run encryption audit script
- Show KMS key policies
- Demonstrate in-transit encryption
Evidence Organization
soc2-evidence/
├── policies/
│ ├── information_security_policy.pdf
│ ├── incident_response_plan.pdf
│ └── change_management_procedure.pdf
├── access_control/
│ ├── user_access_matrix_Q1.xlsx
│ ├── access_review_Q1.pdf
│ └── mfa_enforcement_evidence.json
├── security_config/
│ ├── encryption_audit.json
│ ├── network_diagrams.pdf
│ └── security_group_audit.csv
├── monitoring/
│ ├── cloudwatch_alarms.json
│ ├── incident_logs/
│ └── availability_reports/
└── change_management/
├── change_log_Q1.csv
├── approval_evidence/
└── deployment_history.json
Post-Audit: Maintaining Compliance
Continuous Compliance Automation
# continuous_compliance.py
import boto3
import schedule
import time
class ContinuousCompliance:
def __init__(self):
self.sns_client = boto3.client('sns')
self.topic_arn = 'arn:aws:sns:us-east-1:123456789012:compliance-alerts'
def daily_checks(self):
"""Run daily compliance checks"""
issues = []
# Check for users without MFA
iam = boto3.client('iam')
for user in iam.list_users()['Users']:
if not iam.list_mfa_devices(UserName=user['UserName'])['MFADevices']:
issues.append(f"User {user['UserName']} missing MFA")
# Check for old access keys
for user in iam.list_users()['Users']:
for key in iam.list_access_keys(UserName=user['UserName'])['AccessKeyMetadata']:
age = (datetime.now(timezone.utc) - key['CreateDate']).days
if age > 90:
issues.append(f"Access key {key['AccessKeyId']} is {age} days old")
if issues:
self.send_alert("Daily Compliance Issues", "\n".join(issues))
def weekly_reports(self):
"""Generate weekly compliance reports"""
# Run evidence collection
collector = SOC2EvidenceCollector()
collector.collect_all_evidence()
# Run assessment
assessment = PreAuditAssessment()
assessment.run_assessment()
def send_alert(self, subject, message):
"""Send compliance alert"""
self.sns_client.publish(
TopicArn=self.topic_arn,
Subject=subject,
Message=message
)
# Schedule compliance checks
compliance = ContinuousCompliance()
schedule.every().day.at("09:00").do(compliance.daily_checks)
schedule.every().monday.at("10:00").do(compliance.weekly_reports)
# Run scheduler
while True:
schedule.run_pending()
time.sleep(60)
Common SOC 2 Myths Debunked
Myth 1: “We need to rebuild everything”
Reality: Most startups pass with their existing infrastructure plus security hardening.
Myth 2: “It’s just a paperwork exercise”
Reality: Auditors will test your controls. Scripts and automation make this easier.
Myth 3: “We need expensive tools”
Reality: AWS native services (CloudTrail, Config, GuardDuty) cover 90% of requirements.
Myth 4: “Once we pass, we’re done”
Reality: Type II audits require 6-12 months of continuous compliance evidence.
Your SOC 2 Success Checklist
Must-Haves (Week 1)
- MFA on all accounts
- CloudTrail enabled
- No public S3/RDS
- Encryption enabled
- Security alerts configured
Should-Haves (Month 1)
- Documented policies
- Evidence collection automated
- Change management process
- Incident response plan
- Access reviews completed
Nice-to-Haves (Month 2-3)
- Automated compliance checks
- Security training records
- Penetration test results
- Business continuity plan
- Vendor assessments
Conclusion
SOC 2 doesn’t have to be painful. With proper preparation and automation, you can pass your audit while actually improving your security posture. The key is starting early and automating evidence collection.
Remember: SOC 2 is about demonstrating that you have controls in place and that they’re working. It’s not about perfection—it’s about consistency and continuous improvement.
Start with the critical items (MFA, logging, encryption), automate your evidence collection, and build compliance into your daily operations. Your future self (and your auditor) will thank you.
Need help identifying gaps in your AWS security posture before the audit? Tools like PathShield can automatically scan your environment and generate pre-audit reports showing exactly what needs attention.