What engineers need to know about SOC 2 compliance. Covers Trust Service Criteria, evidence collection, access controls, change management, incident response, and audit preparation.
SOC 2 Compliance: Engineering Team Handbook *Security*
SOC 2 compliance is a foundational requirement for engineering teams shipping software to regulated or enterprise customers. It defines a set of controls across five trust service criteria—security, availability, processing integrity, confidentiality, and privacy—that engineering teams must implement and maintain. This handbook guides engineers through the day-to-day work of meeting SOC 2 controls, from provisioning systems to auditing evidence, with real, repeatable actions, not just checklists. It matters when a customer requests an SOC 2 report, when an auditor visits, or when a security incident exposes a gap in controls.
A security policy is the anchor for all engineering work. It must be versioned, accessible, and enforced. The policy is stored in a Git repository under docs/security/policy.md, with a SECURITY_POLICY_VERSION file containing the current version string.
v1.3.0
All systems reference the policy via a SECURITY_POLICY_URL environment variable. The policy includes:
A failure mode: teams update the policy locally but forget to commit and push. The SECURITY_POLICY_VERSION file is out of sync with the actual policy, leading to ambiguity during audits.
All systems use centralized identity. Every service authenticates against an identity provider (IdP) using OpenID Connect (OIDC) with the auth service at https://auth.example.com.
Each service defines a client_id and redirect_uri in its configuration:
{
"oidc": {
"issuer": "https://auth.example.com",
"client_id": "payment-service",
"redirect_uri": "https://payment.example.com/callback",
"scope": ["openid", "profile", "email", "groups"],
"id_token_timeout_seconds": 3600,
"refresh_token_ttl_seconds": 604800
}
}
Role-based access control (RBAC) is implemented via groups claims in the ID token. Users are assigned to groups like engineering, admin, finance, security.
The groups claim is a JSON array, not a delimited string. A common failure: a service expects ["engineering", "admin"] but the IdP returns ["engineering", "admin"] as a string. The service fails to assign roles.
To audit access, engineers run:
curl -H "Authorization: Bearer <access_token>" \
https://auth.example.com/.well-known/openid-configuration
The response must include authorization_endpoint, token_endpoint, and jwks_uri. A missing jwks_uri causes token validation to fail silently.
New team members receive engineering role via a provisioning script triggered by a user_created event from the HR system.
curl -X POST https://auth.example.com/api/v1/users \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"name": "Alice Chen",
"groups": ["engineering", "onboarding"]
}'
The groups field is processed asynchronously. A service polls https://auth.example.com/api/v1/events for user_created events and applies group membership.
Access reviews are conducted quarterly. The access-review service queries:
SELECT
u.email,
u.name,
ARRAY_AGG(g.name) AS roles,
COUNT(a.access_id) AS active_accesses,
(CURRENT_DATE - MAX(a.last_accessed_at)) AS days_since_last_access
FROM users u
JOIN user_groups ug ON u.id = ug.user_id
JOIN groups g ON ug.group_id = g.id
LEFT JOIN access_logs a ON u.id = a.user_id
WHERE g.name IN ('engineering', 'admin', 'finance')
GROUP BY u.email, u.name;
The result is exported as access_review_q3_2024.csv. Teams review the file and submit changes via a PATCH request to https://auth.example.com/api/v1/access_reviews.
All systems use declarative infrastructure-as-code (IaC) with Terraform. Every environment (dev, staging, prod) has a terraform.tfvars file in environments/<env>/.
# environments/prod/terraform.tfvars
region = "us-east-1"
instance_type = "t3.large"
vpc_cidr = "10.0.0.0/16"
subnet_cidr = "10.0.1.0/24"
enable_encryption = true
enable_iam_roles = true
A security control requires that all EC2 instances are encrypted at rest. The following check runs in CI:
# In CI: verify all instances are encrypted
aws ec2 describe-instances \
--filters "Name=tag:Environment,Values=prod" \
--query "Reservations[].Instances[].BlockDeviceMappings[].Ebs.VolumeId" \
--output json \
--region us-east-1 \
--profile prod
The output is parsed to extract VolumeIds, then:
aws ec2 describe-volumes \
--volume-ids $(cat volumes.json | jq -r '.[] | .VolumeId' | paste -sd ' ')
The response must include Encrypted = true for every volume. A failure: a volume is created but not encrypted. The encrypted field is true, but not true—a capital T is expected. The describe-volumes output shows Encrypted = true, but the service expects true (lowercase).
All services emit logs in structured JSON format using json and timestamp keys.
{
"timestamp": "2024-05-14T12:34:56.789Z",
"level": "info",
"service": "payment-service",
"event": "payment_processed",
"payload": {
"payment_id": "pay_abc123",
"amount": 149.99,
"currency": "USD"
}
}
Logs are ingested into a central log store via journalctl and rsyslog. Each service writes logs to /var/log/<service>.log, and rsyslog forwards them to log.example.com:514.
A critical control requires that all logs are retained for at least 90 days. Engineers verify retention with:
# Query logs from the past 90 days
curl -X POST https://log.example.com/api/v1/query \
-H "Content-Type: application/json" \
-d '{
"from": "2024-05-14T00:00:00Z",
"to": "2024-08-14T23:59:59Z",
"service": "payment-service"
}'
The response includes total_results, results, and query_duration. A failure: the query returns total_results: 0, but logs exist. The root cause is that the timestamp field uses RFC3339 format, but the log service expects ISO8601. The timestamp field is 2024-05-14T12:34:56.789Z, but the log service expects 2024-05-14T12:34:56.789+00:00.
All services are scanned weekly by trivy for container vulnerabilities.
# Run Trivy on a container image
trivy image --exit-code 1 --severity HIGH,CRITICAL \
--format json \
--output scan-results.json \
--ignore-unfixed \
--no-progress \
--cache-dir /var/cache/trivy \
registry.example.com/payment-service:2024.05.14
The output is a JSON file with vulnerabilities array, each with id, title, severity, cvss, fixed_in_version, and links.
A CI pipeline checks for new critical vulnerabilities:
- name: Check Trivy Scan
run: |
python3 -c '
import json
with open("scan-results.json") as f:
data = json.load(f)
critical = [v for v in data["vulnerabilities"] if v["severity"] == "CRITICAL"]
if len(critical) > 0:
print("Critical vulnerabilities found:")
for v in critical:
print(f"- {v["title"]} ({v["id"]})")
exit(1)
'
Critical vulnerabilities must be patched within 14 days. Patching is done by updating the Dockerfile and triggering a CI build.
# Dockerfile
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
python3-pip \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip3 install -r requirements.txt
COPY . .
EXPOSE 8080
CMD ["python3", "app.py"]
The apt-get update command is a common failure: it runs but does not upgrade existing packages. The apt-get upgrade command is missing, leading to outdated packages.
Security testing is embedded in every CI pipeline. A security-tests job runs before merge into main.
- name: Security Tests
run: |
# Run SAST
sonar-scanner -Dsonar.projectKey=payment-service \
-Dsonar.sources=. \
-Dsonar.host.url=https://sonar.example.com
# Run DAST
zap-baseline.py -t https://payment.example.com \
-r baseline-report.html \
-o zap-report.json
The zap-baseline.py script is a custom script that uses OWASP ZAP’s REST API to run a baseline scan.
# zap-baseline.py
import requests
import sys
def run_baseline(target_url, output_file):
# Start ZAP
requests.post('http://zap:8080/JSON/core/action/start/', data={'url': target_url})
# Perform spider
spider = requests.post('http://zap:8080/JSON/spider/action/scan/', data={'url': target_url})
spider_id = spider.json()['scanId']
# Wait for spider
while True:
status = requests.get(f'http://zap:8080/JSON/spider/view/status/', params={'scanId': spider_id})
if status.json()['status'] == '100':
break
time.sleep(5)
# Perform active scan
active = requests.post('http://zap:8080/JSON/ascan/action/scan/', data={'url': target_url, 'scanId': spider_id})
active_id = active.json()['scanId']
# Wait for active scan
while True:
status = requests.get(f'http://zap:8080/JSON/ascan/view/status/', params={'scanId': active_id})
if status.json()['status'] == '100':
break
time.sleep(10)
# Generate report
report = requests.get('http://zap:8080/JSON/core/view/alerts/', params={'scanId': active_id})
with open(output_file, 'w') as f:
f.write(report.text)
if __name__ == '__main__':
if len(sys.argv) < 3:
print('Usage: zap-baseline.py -t <url> -r <report.html> -o <report.json>')
sys.exit(1)
target = sys.argv[sys.argv.index('-t')+1]
report_html = sys.argv[sys.argv.index('-r')+1]
report_json = sys.argv[sys.argv.index('-o')+1]
run_baseline(target, report_html, report_json)
A common failure: the zap-baseline.py script runs, but report.json is empty. The root cause is that the alerts endpoint returns [] but the script expects alerts as a top-level key. The script uses report.json() but the alerts endpoint returns {"alerts": [...]}, not {"alerts": [...]}.
Every control has an evidence record. Engineers use a evidence.yaml file in each service directory.
# services/payment-service/evidence.yaml
control:
id: "SOC2-SEC-1"
title: "Access Control"
description: "All systems require central authentication via OIDC."
evidence:
- type: "config"
path: "config/production.json"
key: "oidc.client_id"
value: "payment-service"
- type: "log"
query: "SELECT * FROM logs WHERE service = 'payment-service' AND event = 'user_logged_in'"
filter: "timestamp >= '2024-05-01T00:00:00Z'"
count: 1000
- type: "api"
url: "https://auth.example.com/api/v1/users"
method: "GET"
headers:
Authorization: "Bearer <token>"
response: "status: 200, body: { \"total\": 1234, \"users\": [...] }"
An auditor checks evidence by validating each entry. A failure: the evidence.yaml file lists a log entry but the query is a raw SQL string, not a query that can be run. The auditor runs the query against the log database and finds only 500 entries, not 1000. The count: 1000 expectation is not met.
All security incidents are logged in a shared incidents.json file in data/incidents/.
{
"id": "INC-2024-05-14-01",
"title": "SQL Injection in Payment Form",
"severity": "critical",
"description": "An attacker exploited a vulnerability in the payment form endpoint.",
"timestamp": "2024-05-14T13:20:00Z",
"status": "in-progress",
"steps": [
{
"step": 1,
"action": "Identified vulnerable endpoint",
"timestamp": "2024-05-14T13:20:00Z",
"user": "[email protected]"
},
{
"step": 2,
"action": "Patched application",
"timestamp": "2024-05-14T14:05:00Z",
"user": "[email protected]"
}
],
"attachments": [
"https://example.com/incidents/INC-2024-05-14-01/screenshot.png",
"https://example.com/incidents/INC-2024-05-14-01/attack-trace.json"
]
}
The status field must be one of: new, in-progress, resolved, closed. A common failure: status is resolved, but the steps array is missing. The auditor expects steps to be present in every resolved incident.
A security dashboard is maintained at https://dashboard.example.com/security. It aggregates data from all services.
The dashboard is updated by a dashboard-update script that:
incidents.json fileprometheus and grafanamonthly-report.md file# dashboard-update
#!/bin/bash
# Gather incident data
curl -s https://example.com/data/incidents/incidents.json | \
jq -r '[
.[] | select(.severity == "critical") | .id
] | join("\n")' > critical-incidents.txt
# Generate report
cat > monthly-report.md << EOF
# Security Report - May 2024
## Incidents
- Critical: $(cat critical-incidents.txt | wc -l)
## Vulnerabilities
- New critical: $(trivy image --exit-code 1 --severity CRITICAL --format json registry.example.com/payment-service:latest | jq -r '.vulnerabilities | length')
## Access Reviews
- Reviewed: $(cat access_review_q3_2024.csv | wc -l)
- Unreviewed: $(cat access_review_q3_2024.csv | grep -v "reviewed" | wc -l)
EOF
The script runs monthly and commits the report to Git. The dashboard shows the report and links to the monthly-report.md file.
This handbook ensures that every engineering team member knows exactly how to contribute to SOC 2 compliance—no guesswork, no vendor-specific assumptions, just precise, repeatable actions.
This page was rewritten on 17 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy