Skip to content
PractitionerMicrosoft Sentinel 10 min readUpdated 2026-09-01

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.

Microsoft SentinelIncident responseKQLSOARAutomation

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.

Code
analytics rule -> alert(s) -> incident -> analyst

Two 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

StatusMeansSet by
Newnobody has picked it upSentinel, on creation
Activesomeone is working itanalyst, or automation
Closeddone, with a classificationanalyst, or automation

Closing requires a classification:

ClassificationUse when
TruePositivereal, and it was malicious
BenignPositivereal activity, but authorised — the rule worked, the behaviour was fine
FalsePositivethe detection was wrong
Undeterminedgenuinely 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

FieldNotes
Incident numbersequential per workspace
Titlefrom the rule, or from alertDetailsOverride
Severityinherited from the alert; editable
StatusNew / Active / Closed
Ownerwho's working it
Entitiesderived from alerts — not writable
Tags / labelsfree-form, useful for filtering
Commentsthe case notes
Classificationrequired on close
First / last activityfrom 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 entities property.

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.

KQL
SecurityIncident
| summarize arg_max(TimeGenerated, *) by IncidentNumber   // ALWAYS

Every query below starts this way, and every query you write should too.

Open queue by severity

KQL
SecurityIncident
| summarize arg_max(TimeGenerated, *) by IncidentNumber
| where Status in ('New', 'Active')
| summarize Open = count() by Severity
| order by Severity asc

Unowned incidents — the self-pick queue

KQL
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 asc

Owner 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

KQL
SecurityIncident
| summarize arg_max(TimeGenerated, *) by IncidentNumber
| where TimeGenerated > ago(30d)
| summarize Incidents = count() by Title
| order by Incidents desc
| take 10

The top row is your tuning target.

Closure quality

KQL
SecurityIncident
| summarize arg_max(TimeGenerated, *) by IncidentNumber
| where Status == 'Closed' and TimeGenerated > ago(30d)
| summarize Count = count() by Classification

A queue closing almost everything as FalsePositive is either genuinely noisy or being triaged carelessly. Both are worth knowing.

Per-analyst workload

KQL
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

Code
.../providers/Microsoft.SecurityInsights/incidents?api-version=2023-11-01

Gives you the Sentinel-native view: incidentNumber, status as New / Active / Closed, owner, labels, providerName.

Microsoft Graph — the Defender API

Code
https://graph.microsoft.com/v1.0/security/incidents

Gives you the unified Defender view: status as active / resolved / redirected, assignedTo as a plain UPN string, customTags, and alerts via $expand.

Which to use

NeedUse
New vs Active distinctionARM — Graph collapses both to active
Sentinel labels, owner objectARM
Alert evidence and entitiesGraph, with ?$expand=alerts
Writing on a unified-portal workspaceGraph — 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:

providerNameproviderIncidentId
Microsoft XDRis the Graph incident id
Azure Sentinelis just the Sentinel number again — no Graph counterpart

So to bridge safely:

Code
ARM incident where providerName == 'Microsoft XDR'
 -> properties.providerIncidentId -> use this as the Graph id

Filter server-side to only the incidents that exist in both places:

Code
$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.

StoreEndpointShows 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

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

JSON
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

  • SecurityIncident is append-only. arg_max or you're counting edits.
  • Owner is a JSON string. parse_json(Owner).userPrincipalName.
  • Sentinel incident numbers and Graph ids are different ID spaces. Bridge via providerIncidentId on Microsoft XDR incidents only.
  • ARM PATCH returns 502 on unified-portal workspaces. Read from ARM, write via Graph.
  • $expand=alerts is 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:none span 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 CanNotDelete lock 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.