SOC 2 Testing Controls: What Engineers Need to Know
SOC 2 is a certification that enterprise customers increasingly require before signing a contract. For SaaS companies, a SOC 2 Type II report is often the difference between winning and losing six-figure deals. Yet most engineering teams treat SOC 2 preparation as a documentation exercise managed by compliance consultants — and then scramble when auditors start asking for evidence that controls actually work.
The engineers who've been through multiple SOC 2 audits know something the documentation-heavy approach misses: auditors don't just want policies. They want evidence. Specifically, they want evidence that your controls operated continuously during the audit period (typically 6-12 months for Type II). The most reliable source of that evidence is an automated test suite that runs in CI every day.
This guide explains the SOC 2 Trust Services Criteria from an engineering perspective and shows you how to map them to concrete, automated tests.
The Five Trust Services Criteria
SOC 2 is built around the AICPA's Trust Services Criteria (TSC). There are five criteria categories, but SOC 2 audits always include Security (CC) — the other four (Availability, Processing Integrity, Confidentiality, Privacy) are optional and selected based on your service commitments.
- Security (CC) — Always required. Also called the Common Criteria.
- Availability (A) — Required if you commit to uptime SLAs.
- Processing Integrity (PI) — Required if data processing accuracy is a commitment.
- Confidentiality (C) — Required if you handle confidential customer data.
- Privacy (P) — Required if you handle personal information.
Most SaaS companies include Security and Availability. If you handle customer data for regulated industries, Confidentiality and Privacy are usually added.
The Common Criteria — Security Controls to Test
The Security criteria (CC1-CC9) are dense, but the ones that require active automated testing fall into a few categories.
CC6.1 — Logical and Physical Access Controls
This criterion requires that access to systems is restricted to authorized users. It maps to the most common type of automated test: authorization tests.
// Jest + Supertest example for API access control testing
const request = require('supertest');
const app = require('../src/app');
describe('CC6.1 — Access Control Tests', () => {
let adminToken, memberToken, guestToken;
beforeAll(async () => {
adminToken = await getTokenForRole('admin');
memberToken = await getTokenForRole('member');
guestToken = await getTokenForRole('guest');
});
describe('Admin-only endpoints', () => {
const adminEndpoints = [
{ method: 'GET', path: '/api/admin/users' },
{ method: 'POST', path: '/api/admin/users' },
{ method: 'DELETE', path: '/api/admin/users/123' },
{ method: 'GET', path: '/api/admin/audit-logs' },
{ method: 'POST', path: '/api/admin/api-keys' }
];
test.each(adminEndpoints)(
'$method $path — member receives 403',
async ({ method, path }) => {
const resp = await request(app)[method.toLowerCase()](path)
.set('Authorization', `Bearer ${memberToken}`);
expect(resp.status).toBe(403);
}
);
test.each(adminEndpoints)(
'$method $path — unauthenticated receives 401',
async ({ method, path }) => {
const resp = await request(app)[method.toLowerCase()](path);
expect(resp.status).toBe(401);
}
);
test.each(adminEndpoints)(
'$method $path — admin receives 200 or 204',
async ({ method, path }) => {
const resp = await request(app)[method.toLowerCase()](path)
.set('Authorization', `Bearer ${adminToken}`);
expect(resp.status).toBeOneOf([200, 201, 204]);
}
);
});
});CC6.2 — Authentication Controls
Password policies, MFA, and session management. Test that weak passwords are rejected, that MFA is enforced for privileged accounts, and that sessions expire.
import pytest
import requests
class TestAuthenticationControls:
"""CC6.2 — Authentication and credential management tests"""
def test_weak_password_rejected(self):
resp = requests.post(f"{BASE_URL}/auth/register", json={
"email": "test@example.com",
"password": "password123" # Common weak password
})
assert resp.status_code == 400
assert "password" in resp.json()["error"].lower()
def test_very_short_password_rejected(self):
resp = requests.post(f"{BASE_URL}/auth/register", json={
"email": "test@example.com",
"password": "Ab1!" # Too short
})
assert resp.status_code == 400
def test_password_complexity_enforced(self):
"""Password must meet complexity requirements"""
weak_passwords = [
"alllowercase1!", # No uppercase
"ALLUPPERCASE1!", # No lowercase
"NoNumbers!!!!", # No digit
"NoSpecial1234", # No special char
]
for password in weak_passwords:
resp = requests.post(f"{BASE_URL}/auth/register", json={
"email": f"test+{password[:5]}@example.com",
"password": password
})
assert resp.status_code == 400, \
f"Password '{password}' should have been rejected"
def test_account_lockout_after_failed_attempts(self):
"""Brute force protection — CC6.2"""
email = "lockout-test@example.com"
# Attempt 10 failed logins
for _ in range(10):
requests.post(f"{BASE_URL}/auth/login", json={
"email": email,
"password": "WrongPassword123!"
})
# 11th attempt — even with correct password — should be locked
resp = requests.post(f"{BASE_URL}/auth/login", json={
"email": email,
"password": "CorrectPassword123!"
})
assert resp.status_code == 429 # Too Many Requests
assert "locked" in resp.json().get("message", "").lower() or \
resp.json().get("code") == "ACCOUNT_LOCKED"
def test_mfa_required_for_admin_accounts(self):
"""Admin accounts must have MFA enabled"""
admin_users = get_all_users_with_role("admin")
for user in admin_users:
assert user["mfa_enabled"] is True, \
f"Admin user {user['email']} does not have MFA enabled"CC7.2 — Anomaly and Incident Detection
SOC 2 requires that you detect and respond to anomalies. This is harder to test automatically than access controls, but you can verify that your detection mechanisms are wired up correctly.
describe('CC7.2 — Anomaly Detection', () => {
test('repeated failed logins trigger security alert', async () => {
const testEmail = `anomaly-test-${Date.now()}@example.com`;
// Trigger multiple failed logins
for (let i = 0; i < 5; i++) {
await api.post('/auth/login', {
email: testEmail,
password: 'wrong-password'
}).catch(() => {});
}
// Verify an alert was generated
const alerts = await adminApi.get('/security-alerts', {
params: { email: testEmail, type: 'failed_login_threshold' }
});
expect(alerts.data.items.length).toBeGreaterThan(0);
expect(alerts.data.items[0].severity).toBe('medium');
});
test('access from new geographic location creates risk event', async () => {
// Simulate login from unusual IP (configured in test environment to trigger geo-risk)
const resp = await api.post('/auth/login', {
email: TEST_USER_EMAIL,
password: TEST_USER_PASSWORD
}, {
headers: { 'X-Forwarded-For': '203.0.113.1' } // Test IP configured as "unusual"
});
// Login may succeed, but a risk event should be logged
const riskEvents = await adminApi.get('/security-events', {
params: { userId: TEST_USER_ID, type: 'new_location' }
});
expect(riskEvents.data.items.length).toBeGreaterThan(0);
});
});Availability Criteria — Uptime and Recovery Testing
If your SOC 2 scope includes Availability (A1), you need to test that your system meets its uptime commitments and that backups and recovery procedures work.
A1.2 — Backup and Recovery
class TestAvailabilityControls:
"""A1.2 — Environmental, software, and data backup tests"""
def test_backup_job_completed_recently(self):
"""Verify last successful backup is less than 24 hours old"""
last_backup = requests.get(
f"{ADMIN_API}/backups/latest",
headers={"Authorization": f"Bearer {admin_token}"}
).json()
backup_time = datetime.fromisoformat(last_backup["completed_at"])
age = datetime.utcnow() - backup_time
assert age.total_seconds() < 86400, \
f"Last backup is {age} old — exceeds 24-hour RTO commitment"
def test_backup_restore_produces_consistent_data(self):
"""Verify backup can be restored and produces correct data"""
# Create a known record
record = requests.post(
f"{ADMIN_API}/test-records",
json={"marker": "restore-test-marker"},
headers={"Authorization": f"Bearer {admin_token}"}
).json()
# Trigger a test backup
backup = requests.post(
f"{ADMIN_API}/backups",
headers={"Authorization": f"Bearer {admin_token}"}
).json()
# Restore to a test environment
restore = requests.post(
f"{ADMIN_API}/restores",
json={"backup_id": backup["id"], "target": "test-env"},
headers={"Authorization": f"Bearer {admin_token}"}
).json()
# Verify the record exists in the restored environment
test_env_record = requests.get(
f"{TEST_ENV_API}/records/{record['id']}"
).json()
assert test_env_record["marker"] == "restore-test-marker"Testing Audit Logging for SOC 2 Evidence
SOC 2 auditors will ask to see logs demonstrating that changes to systems are tracked, that access to sensitive data is logged, and that security events are captured. Your audit logging tests serve double duty: they verify the implementation works and generate CI artifacts that function as evidence.
describe('SOC 2 Audit Log Coverage', () => {
const auditableEvents = [
{
name: 'user_created',
trigger: () => adminApi.post('/users', { email: 'audit-test@example.com' }),
expectedAction: 'USER_CREATED'
},
{
name: 'user_role_changed',
trigger: () => adminApi.patch('/users/test-id', { role: 'admin' }),
expectedAction: 'USER_ROLE_CHANGED'
},
{
name: 'api_key_created',
trigger: () => adminApi.post('/api-keys', { name: 'test-key' }),
expectedAction: 'API_KEY_CREATED'
},
{
name: 'settings_changed',
trigger: () => adminApi.patch('/settings', { mfa_required: true }),
expectedAction: 'SETTINGS_CHANGED'
},
{
name: 'data_exported',
trigger: () => adminApi.post('/exports', { format: 'csv' }),
expectedAction: 'DATA_EXPORTED'
}
];
test.each(auditableEvents)(
'audit log created for $name',
async ({ trigger, expectedAction }) => {
const before = new Date();
await trigger();
const after = new Date();
const logs = await adminApi.get('/audit-logs', {
params: {
action: expectedAction,
startTime: before.toISOString(),
endTime: after.toISOString()
}
});
expect(logs.data.items.length).toBeGreaterThan(0);
const log = logs.data.items[0];
expect(log).toHaveProperty('actor_id');
expect(log).toHaveProperty('timestamp');
expect(log).toHaveProperty('ip_address');
expect(log).toHaveProperty('resource_type');
}
);
});Collecting Evidence for Auditors
SOC 2 Type II auditors test controls by sampling. For a 6-month audit period, they'll typically sample 25-40 instances of each control and verify each one has a record. Your CI pipeline is your best evidence generator.
Structure your test reports to be auditor-friendly:
// jest.config.js — configure output for SOC 2 evidence collection
module.exports = {
reporters: [
'default',
['jest-junit', {
outputDirectory: 'soc2-evidence',
outputName: `security-controls-${new Date().toISOString().split('T')[0]}.xml`,
suiteName: 'SOC 2 Security Controls',
classNameTemplate: '{classname}',
titleTemplate: '{title}'
}],
['jest-html-reporter', {
outputPath: `soc2-evidence/report-${Date.now()}.html`,
pageTitle: 'SOC 2 Control Test Evidence',
includeFailureMsg: true,
includeConsoleLog: true
}]
]
};Archive these reports as CI artifacts and ensure your artifact retention matches your audit period. If you're in a Type II audit covering January through June, you need CI evidence from all six months.
Building the Control-to-Test Mapping
Auditors work from a control matrix. Build yours and map each control to specific tests:
# soc2-controls.yaml — control mapping document
controls:
CC6.1:
name: "Logical Access Controls"
description: "Access to system components is restricted to authorized users"
tests:
- file: tests/access-control/admin-endpoints.test.js
cases: [admin-only-endpoints, member-cannot-access-admin]
- file: tests/access-control/rbac.test.js
cases: [role-permissions-matrix]
evidence_path: soc2-evidence/
last_reviewed: "2024-01-15"
owner: "engineering"
CC6.2:
name: "Authentication"
description: "User authentication controls prevent unauthorized access"
tests:
- file: tests/auth/password-policy.test.py
cases: [weak-password-rejected, complexity-enforced, lockout-after-failures]
- file: tests/auth/mfa.test.py
cases: [mfa-required-for-admin]
evidence_path: soc2-evidence/
last_reviewed: "2024-01-15"
owner: "engineering"The Gap Between Documentation and Reality
The most common SOC 2 finding is controls that exist on paper but aren't consistently applied. A password policy document is not the same as a test that verifies the password policy is enforced by every code path that accepts passwords.
Automated tests close this gap. When your PR pipeline runs test_password_complexity_enforced on every merge, you have continuous evidence that the control works — not just a policy that someone wrote once and filed away.
Start your SOC 2 test suite as early as possible. Building it from scratch in the 6 months before your audit is stressful and the evidence will be thin. Building it alongside your product and running it continuously gives you a year of clean CI artifacts when auditors come knocking.