SOAR playbooks for safe containment with human approval gates
For developers and security engineers wiring response automation into real systems, this guide shows how SOAR playbooks contain incidents without turning one bad signal into an outage. You’ll see where approval gates belong, how an end-to-end containment flow actually executes, and concrete config patterns you can adapt for webhooks, ticketing, and rollback-safe actions.
TL;DR — SOAR playbooks are most valuable when they automate the boring, reversible parts of containment fast, then stop at a clearly defined approval gate before actions that can break production, lock out users, or destroy evidence. The practical pattern is: enrich and score automatically, execute low-risk containment automatically, require human approval for high-blast-radius steps, and always record idempotency keys, rollback steps, and expiration times on every action. Reading time: ~7 min
What it is and where it sits
In an XDR stack, a SOAR playbook is the response orchestration layer between detections and the systems that can actually do something: identity providers, EDR agents, firewalls, MDM, ticketing, chat, email, CMDB, and your own internal services. It does not replace detection. It replaces ad hoc human glue work: copy alert into chat, look up asset owner, query recent auth events, decide whether to isolate a host, open a case, wait for approval, then call three APIs in the right order.
Architecturally, it usually sits off the hot path of user requests. Your app traffic keeps flowing through load balancers, APIs, databases, and queues. Separately, telemetry from those systems lands in your SIEM/XDR or alerting pipeline. The playbook subscribes to those alerts and drives containment through APIs.
What talks to it:
- Upstream: SIEM/XDR detections, EDR alerts, cloud audit logs, IAM risk events, phishing reports, webhook-based custom detections.
- Downstream: identity APIs to disable sessions, EDR APIs to isolate hosts, firewall APIs to block IPs, email APIs to pull messages, ticketing/chat APIs for approvals.
- Side systems: asset inventory, CMDB, HR directory, on-call schedule, secrets manager, evidence store.
What it replaces:
- Shell scripts run manually from a wiki.
- ChatOps commands with no state tracking.
- Analysts pasting indicators between consoles.
- “Did anyone already isolate this host?” uncertainty.
The key design point: SOAR should live in the control plane, not the data plane. If your playbook engine is down, user traffic should still work; you just lose automation and fall back to manual response.
View as text
[App/Infra Logs] ---- 1. detections ----> [SIEM/XDR]
|
| 2. alert webhook
v
[SOAR Playbook]
|
3. enrich via APIs | 4. ask approval
v
[CMDB/IdP/EDR/HR/Chat]
|
| 5. contain / rollback timer
v
[IdP] [EDR] [Firewall] [Ticket]How it actually works
Walk one realistic example: impossible-travel sign-in followed by suspicious OAuth consent from a privileged engineer. Goal: revoke active sessions immediately if confidence is high, but require human approval before disabling the account or isolating the laptop.
Step 1: Ingest the alert and normalize fields
The SIEM/XDR emits a webhook with fields like user, device_id, risk_score, source_ip, geo, oauth_app_name, privilege_level, alert_id, and event_time. The playbook’s first job is normalization, because every source names things differently.
At this stage, generate:
- A correlation key, for example
user + device_id + 1h window. - An idempotency key, for example
sha256(alert_source + alert_id + action_name). - A case record with state
pending_enrichment.
If the same alert retries, the playbook should return “already processed” instead of revoking sessions twice or opening duplicate tickets.
Step 2: Enrich automatically
The playbook queries:
- CMDB/asset inventory: who owns
device_id, is it production-admin capable, is it a shared workstation? - IdP: recent sign-ins, MFA status, active sessions, group membership.
- HR/on-call directory: is the user on approved travel, is the account break-glass, who is the manager?
- Threat intel or internal allowlists: is
source_ipa corporate egress, VPN, or known scanner?
This is where many teams over-automate. Do not jump straight from “alert exists” to “disable account”. First classify whether the signal is reversible and whether the target is sensitive.
Step 3: Score the action, not just the alert
A useful pattern is separate scores:
- Detection confidence: how likely the alert is real.
- Action risk: how much damage the containment step can cause.
Example policy:
- If detection confidence
>= 80and action risk is low: revoke active sessions automatically. - If action risk is medium/high: require approval from incident commander or service owner.
- If target is a break-glass or production deploy account: never auto-disable; page immediately.
Why this split matters: revoking sessions is usually reversible and low blast radius. Disabling an account can stop production deploys, break CI, or lock out a responder. Isolating a host can sever SSH access and kill a live investigation if the EDR isolation mode blocks your tooling.
Step 4: Execute low-risk containment automatically
The playbook revokes active IdP sessions and creates a ticket. It posts a chat message with context and buttons/links for approval on the next step.
Typical state transitions:
pending_enrichment→auto_contained_low_risk- record
action=revoke_sessions,expires_at,rollback=force_reauth_complete approval_requiredfordisable_accountandisolate_host
Step 5: Human approval gate for high-risk actions
The approver should see the exact action, target, reason, and rollback. Not “Approve incident response?” but “Disable account alice@example.com for 4h and isolate host mac-1234; rollback re-enable account and release host isolation.”
Approval should expire. If nobody approves in, say, 15 minutes, escalate to the on-call responder rather than leaving the case hanging forever.
Step 6: Execute, verify, and set rollback timers
After approval, the playbook calls the downstream APIs, then verifies the state changed. Do not trust a 202 Accepted alone; poll for the resulting state if the API is asynchronous.
Examples of verification:
- IdP account status changes from
activetodisabled. - EDR host state changes from
connectedtoisolated. - Firewall block rule appears with the expected comment/tag.
Then attach a rollback timer. Temporary containment should expire unless renewed by a human. A host isolated forever because someone forgot to click “release” is an avoidable outage.
Step 7: Preserve evidence and close cleanly
Before destructive steps like deleting mail, terminating instances, or wiping devices, snapshot evidence references: event IDs, process tree IDs, message IDs, cloud instance IDs, and API responses. The playbook should append to the case, not bury evidence in transient logs.
When to use it (and when not to)
Use SOAR playbooks when the response path is repetitive, API-driven, and bounded by clear safety rules. Don’t use them to automate decisions you haven’t operationally stabilized by hand.
| Scenario | Recommendation |
|---|---|
| Revoke user sessions after high-confidence account compromise | Yes; good auto-containment candidate if you log idempotency and verification |
| Isolate endpoints with known ransomware behavior from EDR detections | Yes, but only if EDR isolation behavior is tested on your admin tooling and rollback is documented |
| Disable privileged accounts based on a single weak signal | No; require approval and enrichment first |
| Block IPs at edge firewall from noisy scanner detections | Maybe; safe if rules are short-lived and scoped, risky if NAT/shared egress can block customers |
| Delete emails or quarantine mailboxes automatically | Usually approval-gated; evidence and business impact are easy to mishandle |
| Run database changes, rotate app secrets, or revoke production service accounts automatically | Usually no, unless you have mature dependency mapping and rollback; blast radius is high |
| Your team handles fewer than a couple of incidents per quarter | You probably don’t need a full SOAR program; start with documented manual runbooks and a few scripts |
You probably don’t need this if:
- Your detections are still too noisy to trust.
- The target systems don’t have reliable APIs.
- You can’t answer “what is the rollback?” for each action.
- You don’t have ownership metadata, so the playbook cannot find the right approver.
Trade-offs
Every benefit comes with operational cost.
- Faster containment
- Cost: false positives turn into faster outages. A bad detection plus auto-disable can become an incident of its own.
- Consistency and auditability
- Cost: you now own workflow state, retries, deduplication, and evidence retention. That is application engineering, not just “security automation”.
- Lower analyst toil
- Cost: integration maintenance. APIs change, tokens expire, scopes drift, and rate limits bite during real incidents.
- Human approval where it matters
- Cost: latency. Approval gates slow response, especially across time zones or if the approver list is stale.
- Reusable playbooks
- Cost: vendor lock-in risk if you encode too much logic in a proprietary workflow DSL. Keep decision logic and action adapters portable where possible.
- Temporary containment with rollback
- Cost: scheduling and state cleanup. Expiration timers, retries, and “what if rollback fails?” need explicit handling.
A practical compromise is to keep core policy in version-controlled code or declarative config, and let the SOAR platform handle triggering, credentials, and UI approvals.
In practice
Example 1: Approval-gated containment policy as declarative config
playbook: account-compromise-containment
version: 1
trigger:
source: webhook
event_type: impossible_travel_with_oauth_consent
policy:
auto_actions:
- name: revoke_sessions
when: detection_confidence >= 80
ttl_minutes: 240
approval_actions:
- name: disable_account
when: detection_confidence >= 80 and privilege_level in ["admin", "prod-access"]
approvers: ["incident_commander", "service_owner"]
expires_minutes: 15
- name: isolate_host
when: device_trust == "managed" and edr_status == "connected"
approvers: ["incident_commander"]
expires_minutes: 15
safety:
never_auto_for:
users: ["breakglass@example.com"]
groups: ["ci-bot", "deploy-bot"]
require_rollback: true
require_idempotency_key: true
This is the policy shape you want even if your actual engine uses JSON or a UI. The gotcha is hidden coupling: privilege_level, device_trust, and edr_status must be normalized before evaluation, or rules silently misfire.
Example 2: Webhook receiver with idempotency and explicit exit behavior
curl -i -X POST http://localhost:8080/webhooks/alerts \
-H 'Content-Type: application/json' \
-H 'X-Signature: sha256=4f3f...' \
-d '{
"alert_id":"det-9f2a",
"user":"alice@example.com",
"device_id":"mac-1234",
"risk_score":92,
"privilege_level":"admin",
"source_ip":"198.51.100.24"
}'
Typical success on first delivery:
HTTP/1.1 202 Accepted
Content-Type: application/json
{"case_id":"case-2026-04-1182","state":"pending_enrichment"}
Typical duplicate delivery with the same idempotency key:
HTTP/1.1 200 OK
Content-Type: application/json
{"case_id":"case-2026-04-1182","state":"already_processed"}
What it does: accepts an alert and starts the playbook asynchronously. Gotcha: return 202 only if you have durably persisted the event; otherwise retries after a crash will drop alerts and you will not know.
Example 3: A minimal action runner with verification and non-zero exit codes
⚠️ Isolating a host or disabling an account can cut off production access and interrupt active investigations. Test against a non-production device/account first, and verify your rollback path before enabling automation.
#!/usr/bin/env bash
set -euo pipefail
API_BASE="${API_BASE:-https://edr.example.internal/api}"
HOST_ID="$1"
TOKEN="${EDR_TOKEN:?missing EDR_TOKEN}"
resp=$(curl -sS -w '\n%{http_code}' -X POST \
"$API_BASE/hosts/$HOST_ID/isolate" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"reason":"soar_case=case-2026-04-1182","ttl_minutes":240}')
body=$(printf '%s' "$resp" | sed '$d')
code=$(printf '%s' "$resp" | tail -n1)
if [[ "$code" != "200" && "$code" != "202" ]]; then
echo "isolate request failed: http=$code body=$body" >&2
exit 20
fi
for i in {1..12}; do
state=$(curl -sS "$API_BASE/hosts/$HOST_ID" \
-H "Authorization: Bearer $TOKEN" | jq -r '.state')
if [[ "$state" == "isolated" ]]; then
echo "host $HOST_ID isolated"
exit 0
fi
sleep 5
done
echo "timeout waiting for isolation on host $HOST_ID" >&2
exit 28
What it does: requests host isolation, then polls until the state is actually isolated. Gotcha: many APIs return 202 Accepted while work is queued; if you treat that as success without verification, your case record can claim containment that never happened.
A few implementation details experienced developers care about:
- Use distinct exit codes. For example
20for API failure,28for timeout. Your orchestrator can retry timeouts but not auth failures. - Log the downstream request ID if the API returns one, so responders can correlate with vendor logs.
- Put TTLs on containment actions. “Block forever” is usually laziness disguised as safety.
- Keep action adapters idempotent. If the same case retries after a network timeout, a second
disable_accountcall should be harmless.
Further reading
- NIST SP 800-61 Computer Security Incident Handling Guide
- MITRE ATT&CK, the "Response" and mitigation mappings
- The OpenAPI Specification
- The "Idempotent Receiver" pattern in Enterprise Integration Patterns
- The "Authentication and Session Management" guidance in the OWASP Cheat Sheet Series
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI