SOC 2 Testing for SaaS: What Engineers Need to Verify
SOC 2 has become the de facto trust signal for B2B SaaS companies. Enterprise buyers require it; without it, deals stall in security review. The American Institute of CPAs (AICPA) defines SOC 2 around five Trust Service Criteria (TSC): Security, Availability, Processing Integrity, Confidentiality, and Privacy. Most companies pursue Security (CC series) plus one or two others relevant to their product.
What many engineering teams don't realize is that SOC 2 is fundamentally an evidence problem. The auditor examines whether your controls existed and operated effectively during a defined observation period (typically 6–12 months). Automated testing is how you generate consistent, datable evidence that controls worked.
Trust Service Criteria and What They Require
| Criteria | Abbreviation | Core Question | Commonly Tested Controls |
|---|---|---|---|
| Security | CC | Are systems protected against unauthorized access? | Access control, encryption, vulnerability management, incident response |
| Availability | A | Do systems operate as committed? | Uptime monitoring, RTO/RPO, capacity management |
| Processing Integrity | PI | Is processing complete, accurate, and timely? | Data validation, error handling, reconciliation |
| Confidentiality | C | Is confidential data protected? | Encryption, access restrictions, disposal |
| Privacy | P | Is personal information collected/used appropriately? | Consent, data subject rights, retention |
Most early-stage SaaS companies pursue SOC 2 Type I (point-in-time) first, then Type II (over an observation period). Type II is what enterprise buyers actually care about — it proves controls operated continuously, not just that they were in place on one day.
CC6: Logical and Physical Access Controls
CC6 is the largest control area in SOC 2 Security. It covers how you prevent unauthorized access to systems and data.
CC6.1 — Access Provisioning and Deprovisioning
# tests/soc2/test_access_controls.py
import pytest
import boto3
from datetime import datetime, timedelta
from myapp.api_client import APIClient
client = APIClient(base_url='https://api.example.com')
iam = boto3.client('iam')
def test_inactive_iam_users_do_not_exist():
"""
SOC 2 CC6.1: Access is removed when no longer needed.
IAM users with no activity in 90 days should be deprovisioned.
"""
response = iam.generate_credential_report()
# Wait for report to generate
import time
time.sleep(2)
report = iam.get_credential_report()
import csv
import io
reader = csv.DictReader(io.StringIO(report['Content'].read().decode('utf-8')))
violations = []
cutoff = datetime.utcnow() - timedelta(days=90)
for user in reader:
if user['user'] == '<root_account>':
continue
last_used = user.get('password_last_used', 'N/A')
if last_used not in ('N/A', 'no_information', 'not_supported'):
last_used_dt = datetime.strptime(last_used, '%Y-%m-%dT%H:%M:%S+00:00')
if last_used_dt < cutoff:
violations.append({
'user': user['user'],
'last_used': last_used,
})
assert len(violations) == 0, (
f"Found {len(violations)} IAM users inactive for 90+ days: "
+ ', '.join(v['user'] for v in violations)
)
def test_mfa_enabled_for_all_iam_users():
"""SOC 2 CC6.1: MFA required for all human IAM users."""
users = iam.list_users()['Users']
violations = []
for user in users:
mfa_devices = iam.list_mfa_devices(UserName=user['UserName'])['MFADevices']
if len(mfa_devices) == 0:
violations.append(user['UserName'])
assert len(violations) == 0, (
f"IAM users without MFA: {', '.join(violations)}"
)
def test_root_account_has_no_access_keys():
"""SOC 2 CC6.1: Root account should not have programmatic access keys."""
report = iam.get_credential_report()
import csv, io
reader = csv.DictReader(io.StringIO(report['Content'].read().decode('utf-8')))
for user in reader:
if user['user'] == '<root_account>':
assert user['access_key_1_active'] == 'false', "Root account has active access key 1"
assert user['access_key_2_active'] == 'false', "Root account has active access key 2"
def test_api_requires_authentication():
"""SOC 2 CC6.1: All protected endpoints require valid authentication."""
protected_endpoints = [
'/api/users',
'/api/settings',
'/api/billing',
'/api/data-export',
]
for endpoint in protected_endpoints:
response = client.get(endpoint)
assert response.status_code == 401, (
f"Endpoint {endpoint} returned {response.status_code} without auth (expected 401)"
)CC6.3 — Role-Based Access Control
// tests/soc2/rbac.test.ts
import request from 'supertest';
import { app } from '../../src/app';
const ROLES = {
admin: 'admin-token',
member: 'member-token',
viewer: 'viewer-token',
billing_only: 'billing-token',
};
describe('SOC 2 CC6.3 — Role-Based Access Control', () => {
const writeEndpoints = [
{ method: 'POST', path: '/api/users/invite' },
{ method: 'DELETE', path: '/api/users/some-user-id' },
{ method: 'PUT', path: '/api/settings/security' },
];
writeEndpoints.forEach(({ method, path }) => {
it(`${method} ${path} should be restricted to admins`, async () => {
// Members and viewers should be forbidden
for (const role of ['member', 'viewer', 'billing_only'] as const) {
const res = await (request(app) as any)[method.toLowerCase()](path)
.set('Authorization', `Bearer ${ROLES[role]}`);
expect(res.status).toBe(403);
}
// Admins should be permitted (may still fail on payload, but not auth)
const adminRes = await (request(app) as any)[method.toLowerCase()](path)
.set('Authorization', `Bearer ${ROLES.admin}`);
expect(adminRes.status).not.toBe(403);
});
});
it('billing staff can only access billing endpoints', async () => {
// Can access billing
await request(app)
.get('/api/billing/invoices')
.set('Authorization', `Bearer ${ROLES.billing_only}`)
.expect(200);
// Cannot access user management
await request(app)
.get('/api/users')
.set('Authorization', `Bearer ${ROLES.billing_only}`)
.expect(403);
});
});CC7: System Operations — Logging and Monitoring
SOC 2 CC7 requires that you detect and respond to security events. This means logging, alerting, and verified detection capabilities.
// tests/soc2/logging.test.ts
import request from 'supertest';
import { app } from '../../src/app';
import { auditLog } from '../../src/audit-log';
describe('SOC 2 CC7 — Audit Logging', () => {
it('successful login should generate an audit log entry', async () => {
const spy = jest.spyOn(auditLog, 'write');
await request(app)
.post('/api/auth/login')
.send({ email: 'user@example.com', password: 'ValidPass123!' })
.expect(200);
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
event_type: 'auth.login.success',
user_email: 'user@example.com',
timestamp: expect.any(String),
ip_address: expect.any(String),
})
);
});
it('failed login should generate an audit log entry', async () => {
const spy = jest.spyOn(auditLog, 'write');
await request(app)
.post('/api/auth/login')
.send({ email: 'user@example.com', password: 'WrongPassword' })
.expect(401);
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
event_type: 'auth.login.failure',
user_email: 'user@example.com',
})
);
});
it('privilege escalation should be logged', async () => {
const spy = jest.spyOn(auditLog, 'write');
await request(app)
.post('/api/users/member-id/promote')
.set('Authorization', 'Bearer admin-token')
.send({ role: 'admin' })
.expect(200);
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
event_type: 'access.role.changed',
target_user_id: 'member-id',
new_role: 'admin',
performed_by: expect.any(String),
})
);
});
it('audit logs should be immutable (no delete endpoint)', async () => {
await request(app)
.delete('/api/audit-logs/some-log-id')
.set('Authorization', 'Bearer admin-token')
.expect(404);
});
it('logs should be retained for minimum 12 months', async () => {
// Test that old logs are not purged prematurely
const { db } = await import('../../src/db');
const oldLog = await db('audit_logs')
.where('created_at', '<', db.raw("NOW() - INTERVAL '11 months'"))
.first();
// If we have data going back 11 months, verify it's still there
if (oldLog) {
expect(oldLog).toBeDefined();
}
// If not, we can't test this yet — but document the retention policy check
});
});CC8: Change Management
SOC 2 requires that changes to production systems go through a defined process — typically including code review, testing, and approval. These controls are partially enforced by your SCM and CI/CD pipeline.
# tests/soc2/test_change_management.py
import requests
import os
GITHUB_TOKEN = os.environ['GITHUB_TOKEN']
REPO = 'myorg/myapp'
HEADERS = {'Authorization': f'token {GITHUB_TOKEN}'}
def test_main_branch_requires_pull_request():
"""SOC 2 CC8.1: Changes to production branch require PR (no direct push)."""
response = requests.get(
f'https://api.github.com/repos/{REPO}/branches/main/protection',
headers=HEADERS
)
assert response.status_code == 200, "Branch protection not configured on main"
protection = response.json()
assert protection.get('required_pull_request_reviews') is not None, \
"Pull request reviews not required on main branch"
def test_pr_requires_minimum_reviewers():
"""SOC 2 CC8.1: PRs require at least one approving review."""
response = requests.get(
f'https://api.github.com/repos/{REPO}/branches/main/protection',
headers=HEADERS
)
protection = response.json()
pr_config = protection.get('required_pull_request_reviews', {})
required_count = pr_config.get('required_approving_review_count', 0)
assert required_count >= 1, (
f"Expected at least 1 required reviewer, got {required_count}"
)
def test_status_checks_required_before_merge():
"""SOC 2 CC8.1: CI tests must pass before merging to main."""
response = requests.get(
f'https://api.github.com/repos/{REPO}/branches/main/protection',
headers=HEADERS
)
protection = response.json()
checks = protection.get('required_status_checks', {})
contexts = checks.get('contexts', [])
required_checks = ['test', 'lint', 'security-scan']
for check in required_checks:
matching = [c for c in contexts if check in c.lower()]
assert len(matching) > 0, (
f"Required status check '{check}' not configured on main branch"
)
def test_no_force_push_on_main():
"""SOC 2 CC8.1: Force pushes to production branch are blocked."""
response = requests.get(
f'https://api.github.com/repos/{REPO}/branches/main/protection',
headers=HEADERS
)
protection = response.json()
assert protection.get('allow_force_pushes', {}).get('enabled') == False, \
"Force pushes allowed on main branch — this bypasses the change management process"CC9 and A-Series: Availability Testing
If you've scoped Availability into your SOC 2, you need evidence that systems meet your committed uptime and that recovery procedures work.
# tests/soc2/test_availability.py
import requests
import time
import pytest
BASE_URL = 'https://api.example.com'
def test_health_endpoint_responds():
"""SOC 2 A1: Core system availability verified."""
response = requests.get(f'{BASE_URL}/health', timeout=10)
assert response.status_code == 200
health = response.json()
assert health.get('status') == 'healthy'
assert health.get('database') == 'connected'
assert health.get('cache') == 'connected'
def test_api_response_time_under_sla():
"""SOC 2 A1.2: System operates within committed performance levels."""
endpoints = [
'/api/users/me',
'/api/projects',
'/api/settings',
]
SLA_MS = 2000 # 2 second SLA
for endpoint in endpoints:
start = time.time()
response = requests.get(
f'{BASE_URL}{endpoint}',
headers={'Authorization': 'Bearer test-token'},
timeout=10
)
elapsed_ms = (time.time() - start) * 1000
assert elapsed_ms < SLA_MS, (
f"{endpoint} took {elapsed_ms:.0f}ms, exceeding SLA of {SLA_MS}ms"
)
assert response.status_code in (200, 401), (
f"{endpoint} returned unexpected status {response.status_code}"
)Automated Evidence Collection
SOC 2 audits require evidence. Instead of scrambling to collect screenshots and logs at audit time, automate evidence collection so it runs on schedule:
// scripts/soc2-evidence-collector.ts
import { writeFileSync, mkdirSync } from 'fs';
import { execSync } from 'child_process';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
const s3 = new S3Client({ region: 'us-east-1' });
const EVIDENCE_BUCKET = 'myapp-soc2-evidence';
const today = new Date().toISOString().split('T')[0];
async function collectEvidence() {
const evidence: Record<string, unknown> = {
collected_at: new Date().toISOString(),
period: today,
};
// 1. Security group configurations
const sgOutput = execSync(
'aws ec2 describe-security-groups --filters Name=tag:Environment,Values=production --output json'
).toString();
evidence.security_groups = JSON.parse(sgOutput);
// 2. IAM credential report
execSync('aws iam generate-credential-report');
await new Promise(r => setTimeout(r, 3000));
const credReport = execSync('aws iam get-credential-report --output json').toString();
evidence.iam_credential_report = JSON.parse(credReport);
// 3. CloudTrail status (audit logging active)
const trailStatus = execSync(
'aws cloudtrail get-trail-status --name prod-trail --output json'
).toString();
evidence.cloudtrail_status = JSON.parse(trailStatus);
// 4. Test results
try {
const testOutput = execSync('npx jest tests/soc2/ --json 2>/dev/null').toString();
evidence.test_results = JSON.parse(testOutput);
} catch (e: any) {
evidence.test_results = { error: e.message };
}
const filename = `evidence-${today}.json`;
const content = JSON.stringify(evidence, null, 2);
// Save locally
mkdirSync('soc2-evidence', { recursive: true });
writeFileSync(`soc2-evidence/${filename}`, content);
// Upload to S3 with retention policy
await s3.send(new PutObjectCommand({
Bucket: EVIDENCE_BUCKET,
Key: `daily/${filename}`,
Body: content,
ContentType: 'application/json',
ServerSideEncryption: 'aws:kms',
}));
console.log(`Evidence collected and uploaded: ${filename}`);
}
collectEvidence().catch(console.error);Schedule this to run daily during your observation period:
# .github/workflows/soc2-evidence.yml
name: SOC 2 Evidence Collection
on:
schedule:
- cron: '0 8 * * *' # Daily at 8am UTC
jobs:
collect:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/soc2-evidence-collector
aws-region: us-east-1
- name: Collect evidence
run: npx ts-node scripts/soc2-evidence-collector.ts
- name: Run SOC 2 control tests
run: npx jest tests/soc2/ --ciVendor and Subprocessor Management
SOC 2 CC9.2 requires monitoring third-party risks. Every SaaS vendor that touches your data must be assessed.
# tests/soc2/test_vendor_compliance.py
REQUIRED_VENDOR_CERTS = {
'AWS': ['SOC 2 Type II', 'ISO 27001'],
'Stripe': ['SOC 2 Type II', 'PCI DSS Level 1'],
'SendGrid': ['SOC 2 Type II'],
'Datadog': ['SOC 2 Type II'],
}
VENDOR_CERT_FILE = 'compliance/vendor-certs.yaml'
def test_vendor_cert_file_exists():
"""SOC 2 CC9.2: Vendor compliance documentation must be maintained."""
import os
assert os.path.exists(VENDOR_CERT_FILE), (
f"Vendor certification file not found: {VENDOR_CERT_FILE}. "
f"Create and maintain this as part of your vendor management program."
)
def test_vendor_certs_documented():
"""SOC 2 CC9.2: All key vendors have documented compliance certifications."""
import yaml
with open(VENDOR_CERT_FILE) as f:
documented = yaml.safe_load(f)
for vendor, required_certs in REQUIRED_VENDOR_CERTS.items():
assert vendor in documented, f"Vendor {vendor} not documented in {VENDOR_CERT_FILE}"
for cert in required_certs:
assert cert in documented[vendor].get('certifications', []), (
f"Vendor {vendor} missing required certification: {cert}"
)
def test_vendor_certs_not_expired():
"""SOC 2 CC9.2: Vendor certifications must be current."""
import yaml
from datetime import datetime
with open(VENDOR_CERT_FILE) as f:
documented = yaml.safe_load(f)
today = datetime.utcnow().date()
for vendor, data in documented.items():
cert_expiry = data.get('cert_expiry')
if cert_expiry:
expiry_date = datetime.strptime(cert_expiry, '%Y-%m-%d').date()
assert expiry_date >= today, (
f"Vendor {vendor} certification expired on {cert_expiry}. "
f"Obtain updated certification report."
)From Testing to Audit Readiness
The gap between "we run these tests" and "we are SOC 2 compliant" is mostly documentation and consistency. Auditors want to see:
- Control documentation — what the control is, who owns it, how often it runs
- Evidence of consistent operation — test results, not just assertions that controls work
- Exception handling — what happened when a control failed, and how you responded
Automated testing covers the "consistent operation" column. Pair it with:
- A control matrix mapping each TSC criterion to your specific controls
- A ticketing workflow so test failures become tracked remediation tasks
- Quarterly internal reviews before the auditor arrives
SOC 2 is achievable for engineering teams. The discipline it builds — access control, audit logging, change management, vendor oversight — makes your system more secure regardless of whether a customer ever requests your audit report.