SOC 2 Compliance: Engineering Team Handbook

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.

Implementing the Security Trust Service Criteria

Define and Maintain a Security Policy

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.

Configure and Enforce Identity and Access Management

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.

Automate Access Request and Review Workflows

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.

Enforce Secure Configuration of Infrastructure

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).

Implement and Monitor Logging and Monitoring

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.

Automate Vulnerability Scanning and Patching

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.

Perform Regular Security Testing and Audits

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": [...]}.

Maintain and Validate Audit Evidence

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.

Respond to Incidents and Maintain an Incident Register

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.

Maintain a Security Dashboard and Run Monthly Reviews

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:

# 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.