Working incidents in Microsoft Sentinel
The incident object, its lifecycle and classifications, how entities make it investigable, the KQL to measure the queue, and the two API surfaces that write to it — including the ID trap that silently corrupts automation.
The incident queue is where analysts spend their day, and where most of a SOC's real work happens. This covers the incident object itself, its lifecycle, how to work the queue, the KQL to measure it, and the two API surfaces that write to it — including a numbering trap that will silently corrupt automation if you don't know about it.
What an incident actually is
An incident is a container for one or more alerts, plus the case metadata a human needs: owner, status, severity, classification, comments.
analytics rule -> alert(s) -> incident -> analystTwo consequences that matter constantly:
Incidents inherit almost everything from their alerts. Entities, timestamps, the underlying evidence — all of it comes from the alerts underneath. The incident adds case-management fields on top.
The alert-to-incident ratio is a design choice, set by the rule's event grouping and incident grouping. One rule can produce 50 alerts and either 50 incidents or one, depending entirely on configuration.
The lifecycle
| Status | Means | Set by |
|---|---|---|
| New | nobody has picked it up | Sentinel, on creation |
| Active | someone is working it | analyst, or automation |
| Closed | done, with a classification | analyst, or automation |
Closing requires a classification:
| Classification | Use when |
|---|---|
TruePositive | real, and it was malicious |
BenignPositive | real activity, but authorised — the rule worked, the behaviour was fine |
FalsePositive | the detection was wrong |
Undetermined | genuinely cannot tell |
BenignPositive vs FalsePositive is not pedantry. False positive means fix the
rule. Benign positive means the rule is correct, this was expected activity. If
analysts close authorised admin behaviour as FalsePositive, your tuning data is wrong
and you will weaken a detection that was working.
Undetermined is legitimate and underused. An honest "we couldn't establish this"
beats a guessed FalsePositive that quietly becomes evidence the rule is noisy.
Anatomy of an incident
| Field | Notes |
|---|---|
| Incident number | sequential per workspace |
| Title | from the rule, or from alertDetailsOverride |
| Severity | inherited from the alert; editable |
| Status | New / Active / Closed |
| Owner | who's working it |
| Entities | derived from alerts — not writable |
| Tags / labels | free-form, useful for filtering |
| Comments | the case notes |
| Classification | required on close |
| First / last activity | from the underlying events, not incident creation time |
First activity time is not creation time. An incident created at 09:00 may describe
activity from 03:00 — ingestion and the rule schedule sit between the event and the
alert. When you build a timeline, use the activity times, not CreatedTime.
Entities — the part that makes it investigable
Entities are the clickable IPs, accounts, hosts and resources on an incident. They drive the investigation graph, the entity pages, and most automation.
Entities come from alerts, and alerts get them from the rule's entityMappings.
There is no other source.
The Sentinel incident resource has no writable
entitiesproperty.
The full property list is additionalData, classification, classificationComment,
classificationReason, createdTimeUtc, firstActivityTimeUtc, incidentNumber,
incidentUrl, labels, lastActivityTimeUtc, lastModifiedTimeUtc, owner,
providerIncidentId, providerName, relatedAnalyticRuleIds, severity, status,
title. No entities.
So a Logic App that creates incidents by PUTing to the incidents API produces
text-only incidents with nothing to pivot on, and no amount of post-processing
fixes it. If you want entities, the alert must come from a rule with entity mappings.
The rule of thumb: detection belongs in analytics rules, remediation belongs in playbooks.
Working the queue
Filtering that actually helps
The default view is everything. Two filters make it usable:
- Status: New, Active — hide closed
- Severity: High, Medium — triage top-down
Then sort by Last update, not creation — an incident someone is actively working is more interesting than an old untouched one.
Ownership models
Self-pick — incidents stay unassigned and analysts claim what they will work. Suits teams where availability varies. Requires discipline: unclaimed incidents are nobody's problem by default.
Auto-assign — automation distributes incidents round-robin or by rule. Guarantees an owner, but assigns work to people who may not be available, and an assigned-but-untouched incident looks handled when it isn't.
Whichever you choose, make it explicit. The failure mode of self-pick is a growing unowned backlog; the failure mode of auto-assign is false accountability.
If you run self-pick, automation must not touch owned incidents. An automated action
that overwrites owner destroys the record of who was working it — and if it also
closes the incident, it destroys their work in progress.
The investigation graph
View full details -> Investigate opens the graph: entities as nodes, expandable to
related alerts and activity. Genuinely useful for lateral pivots — "what else did this
IP touch". It only works with mapped entities. An incident without them shows an empty
graph, which is the most visible symptom of a rule missing entityMappings.
Querying incidents in KQL
The single most important thing
SecurityIncident is append-only. Every status change, assignment, comment and
classification writes a new row. Counting it directly counts edits, not incidents.
SecurityIncident
| summarize arg_max(TimeGenerated, *) by IncidentNumber // ALWAYSEvery query below starts this way, and every query you write should too.
Open queue by severity
SecurityIncident
| summarize arg_max(TimeGenerated, *) by IncidentNumber
| where Status in ('New', 'Active')
| summarize Open = count() by Severity
| order by Severity ascUnowned incidents — the self-pick queue
SecurityIncident
| summarize arg_max(TimeGenerated, *) by IncidentNumber
| where Status == 'New'
| extend OwnerUPN = tostring(parse_json(Owner).userPrincipalName)
| where isempty(OwnerUPN)
| project IncidentNumber, Title, Severity, CreatedTime
| order by Severity asc, CreatedTime ascOwner is a JSON string, not a plain UPN — use
parse_json(Owner).userPrincipalName. Comparing Owner directly to a UPN silently
matches nothing.
Which rules generate the most incidents
SecurityIncident
| summarize arg_max(TimeGenerated, *) by IncidentNumber
| where TimeGenerated > ago(30d)
| summarize Incidents = count() by Title
| order by Incidents desc
| take 10The top row is your tuning target.
Closure quality
SecurityIncident
| summarize arg_max(TimeGenerated, *) by IncidentNumber
| where Status == 'Closed' and TimeGenerated > ago(30d)
| summarize Count = count() by ClassificationA queue closing almost everything as FalsePositive is either genuinely noisy or being
triaged carelessly. Both are worth knowing.
Per-analyst workload
let analysts = datatable(UPN:string)['[email protected]', '[email protected]'];
SecurityIncident
| summarize arg_max(TimeGenerated, *) by IncidentNumber
| extend UPN = tostring(parse_json(Owner).userPrincipalName)
| summarize Worked = count(), Closed = countif(Status == 'Closed') by UPN
| join kind=rightouter analysts on UPN
| project UPN = coalesce(UPN, UPN1), Worked = coalesce(Worked, 0), Closed = coalesce(Closed, 0)The rightouter join keeps analysts with zero activity visible. A plain
summarize by silently drops them — which is exactly the person you wanted to notice.
The two API surfaces
There are two ways to read and write incidents, and which one works depends on your workspace.
ARM — the Sentinel API
.../providers/Microsoft.SecurityInsights/incidents?api-version=2023-11-01Gives you the Sentinel-native view: incidentNumber, status as
New / Active / Closed, owner, labels, providerName.
Microsoft Graph — the Defender API
https://graph.microsoft.com/v1.0/security/incidentsGives you the unified Defender view: status as active / resolved /
redirected, assignedTo as a plain UPN string, customTags, and alerts via
$expand.
Which to use
| Need | Use |
|---|---|
| New vs Active distinction | ARM — Graph collapses both to active |
| Sentinel labels, owner object | ARM |
| Alert evidence and entities | Graph, with ?$expand=alerts |
| Writing on a unified-portal workspace | Graph — ARM PATCH returns 502 |
On a workspace onboarded to the unified Defender portal, ARM writes fail. PATCH
against Microsoft.SecurityInsights/incidents returns 502 regardless of caller,
payload or api-version. Reads (GET) and DELETE work normally, which makes it a
genuinely confusing failure — it looks intermittent rather than structural.
So the working pattern on a unified workspace is: read from ARM, write via Graph.
Two things that will bite
$expand=alerts is mandatory to get alerts. A plain
GET /security/incidents/{id} returns no alerts property at all — not an empty
array, absent entirely. A defensive coalesce(..., '[]') then turns "I didn't ask for
it" into a silent "there's nothing there", and your automation runs green doing nothing.
Graph enforces a 1,000-character limit on comments. Longer ones are rejected with
InvalidInput: Maximum comment length is 1000 characters, received 1669. The Sentinel
comments API has no such cap.
Two ID spaces that look identical
This one is worth reading twice, because it silently corrupts automation.
A Sentinel incidentNumber and a Graph incident id are different identifiers that
are both small integers. Sentinel incident 631 and Graph incident 631 are different
incidents — one might be OAuth application consent grant, the other Unapproved
storage container created. Completely unrelated.
If you list from ARM and then write to Graph using incidentNumber, you will
confidently modify the wrong incident.
The correct mapping
On a unified workspace, ARM incidents carry a provider field:
providerName | providerIncidentId |
|---|---|
Microsoft XDR | is the Graph incident id |
Azure Sentinel | is just the Sentinel number again — no Graph counterpart |
So to bridge safely:
ARM incident where providerName == 'Microsoft XDR'
-> properties.providerIncidentId -> use this as the Graph idFilter server-side to only the incidents that exist in both places:
$filter=properties/providerName eq 'Microsoft XDR' and properties/status eq 'New'Azure Sentinel-provider incidents have no Graph counterpart, and writing to them via
Graph would target the wrong object — or 502 via ARM.
Comments
Comments are the case notes: what you checked, what you found, what you decided.
There are two separate comment stores, and they do not sync.
| Store | Endpoint | Shows in |
|---|---|---|
| Sentinel | .../incidents/{sentinelGuid}/comments (ARM PUT) | Azure portal Comments tab |
| Graph | /security/incidents/{graphId}/comments (POST) | Defender portal |
A Graph comment POST can return 200 with the text echoed back while the Sentinel
comments endpoint for the same incident returns an empty array and the portal
Comments tab shows nothing. Only close / classification state syncs Defender ->
Sentinel. So automation that writes one leaves the report invisible in the other
portal. Write both.
Formatting
Sentinel comments render HTML — <b>, <ul>, <li>, <p> all work. Graph
comments are plain text.
Sentinel's sanitiser strips HTML comments. A <!--marker--> written into a comment
is silently removed on write. If you use a hidden marker for deduplication, use
<span style="display:none">marker</span> instead — the style attribute survives
(normalised to display: none) and the inner text is preserved. Automation that dedupes
on a marker the platform strips will re-comment on every poll, forever.
Closing an incident properly
Portal
Set Status -> Closed, pick a classification, and write a comment saying why. The comment is not optional in practice — a closed incident with no reasoning is unauditable.
API — Graph
PATCH https://graph.microsoft.com/v1.0/security/incidents/{id}
{
"status": "resolved",
"classification": "truePositive",
"determination": "multiStagedAttack",
"assignedTo": "[email protected]",
"resolvingComment": "IP confirmed hostile, blocked at the perimeter.",
"customTags": ["soar-closed"]
}resolvingComment is what appears in the portal's Reason for closing box —
distinct from the comment thread. Setting both gives you the reasoning in both places.
API — ARM (non-unified workspaces)
PUT .../incidents/{guid}?api-version=2023-11-01
{ "properties": { "status": "Closed", "classification": "TruePositive",
"classificationComment": "...", "title": "...", "severity": "..." } }ARM incident writes are a full PUT — include title and severity or you will
blank them.
Never auto-close what you didn't check
If automation closes an incident, the classification is an assertion nobody verified — and once it's closed, nobody ever will.
- Only auto-close when you actually evaluated the thing. An incident with no IP entity, closed by an IP-reputation playbook, was dismissed on the basis of a check that never ran.
- Never auto-close an incident that has an owner. Someone is working it.
Automating incident actions
- Automation rules — no-code, trigger on creation or update, can set owner, severity, tags, or run a playbook.
- Playbooks — Logic Apps, arbitrary logic.
A playbook that reacts to incidents needs either the Sentinel connector trigger (which requires interactive OAuth consent and so can't be fully scripted) or a polling loop over the APIs above.
Enrichment is the highest-value, lowest-risk automation. Gathering the evidence an analyst would gather — IP reputation, prior activity, related sign-ins — and writing it as a comment saves real time and cannot make a wrong call. Automated closure is where the risk lives.
Gotchas, collected
SecurityIncidentis append-only.arg_maxor you're counting edits.Owneris a JSON string.parse_json(Owner).userPrincipalName.- Sentinel incident numbers and Graph ids are different ID spaces. Bridge via
providerIncidentIdonMicrosoft XDRincidents only. - ARM
PATCHreturns 502 on unified-portal workspaces. Read from ARM, write via Graph. $expand=alertsis required or the alerts property is absent entirely.- Graph comments cap at 1,000 characters. Sentinel comments don't.
- Graph and Sentinel comment stores don't sync. Write both.
- Sentinel strips HTML comments from comment text. Use a
display:nonespan for hidden markers. - Incidents have no writable entities property. Entities come from alerts only.
- First activity time is not created time. Use activity times for timelines.
- A
CanNotDeletelock on the resource group blocks deleting incident comments (ScopeLocked). Lift it briefly if you need to. - Deleting an analytics rule does not delete its incidents. They stay in the queue.
Spotted an error or something out of date? Let me know.