It is 09:00 on a Monday. You open the Microsoft Entra admin center, glance at Identity Protection, and the Risky users report is clean — zero users at risk. You move on. What you did not see is that at 02:41 on Saturday morning, a leaked-credentials detection fired against a finance account, rated high. A risk-based Conditional Access policy did exactly what it was configured to do: it challenged the sign-in for MFA. The user — or whoever was holding a stolen session — completed MFA, the sign-in was allowed, and Identity Protection marked the risk remediated. The account dropped off the Risky users report before anyone woke up. The dashboard is telling you the truth about its own state, and it is still hiding a detection worth a phone call.
This post is about the gap between "no active risky users" and "nothing risky happened." Entra ID Protection is very good at closing risk automatically — through self-remediation flows and admin bulk-dismissal — and every one of those closures is recorded. The trouble is that the closed records live in the riskDetection data, not on the tile you actually look at. Below: exactly how a genuine detection ends up marked remediated or dismissed, how to confirm one detection's real history manually before you trust anything, and a read-only PowerShell audit that resurfaces every dismissed and auto-remediated detection in a lookback window so you can decide, deliberately, whether each one deserved to be closed.
The problem: the report only shows what is still "at risk"
Identity Protection tracks two things that matter here. A risk detection is a single event — a leaked credential, an anonymous IP address, an atypical-travel sign-in. A risky user is the aggregate risk state that rolls those detections up per user. The Risky users report, and the "users at risk" counters most teams glance at, are built around users whose current risk state is atRisk. That is the entire point of the report: it shows you the population that still needs action.
So what happens the moment a detection is handled? The user's risk state moves off atRisk to either remediated or dismissed, and they leave the active view. Nobody deleted anything. Nobody is lying to you. The report is doing its job — it is showing the outstanding queue, not the history. But if you have quietly trained yourself to treat "zero risky users" as "no incidents," you are reading a queue length as an all-clear, and those are not the same statement.
The six possible values of riskState are fixed and documented: none, atRisk, confirmedSafe, remediated, dismissed, and confirmedCompromised. Only one of them — atRisk — keeps a user on the report you were looking at. The other five all mean "not currently on the queue," and two of them (remediated and dismissed) get reached automatically or in bulk, without a per-detection human decision.
It is worth being precise about which surfaces this affects, because "the report" is really three of them. The Risky users report rolls risk up per user and only lists users whose aggregate state is atRisk. The Risky sign-ins report shows individual sign-ins, but a sign-in whose risk has been remediated stops carrying an active risk flag there too. And the alerting emails Identity Protection can send fire when a user crosses a risk threshold, not when a detection quietly closes itself moments later. All three surfaces are built around the live queue, so a detection that opens and closes inside the same automated cycle can slip past every one of them without ever demanding a human's attention. That is not a bug — it is the intended behaviour of a queue — but it is the reason a monthly backward-looking sweep is a different and necessary view.
Why it happens: riskState tells you it closed, riskDetail tells you how
Every risk detection and every risky user carries two properties that, read together, tell the whole story. riskState is the outcome — where the risk ended up. riskDetail is the reason — the specific action that moved it there. If you only ever look at the first, you cannot tell the difference between "a security analyst reviewed this and confirmed it safe" and "the risk closed itself because the account passed an MFA prompt." Those are wildly different levels of assurance, and they are distinguished entirely by riskDetail.
Here is the mechanism in the leaked-credentials example, step by step. It is worth tracing because the point where visibility is lost is not obvious until you see it laid out.
Microsoft's own investigation guidance is explicit that self-remediation is a designed outcome: "When risk is detected, the recommended first step is self-remediation, if it's an option," through self-service password reset or the remediation flow of a risk-based Conditional Access policy. That is genuinely good hygiene for the common case — it clears noise so analysts can focus. It just also means the closure of a high-severity detection can happen with no analyst in the loop at all.
The riskDetail value is where you recover the missing context. It is an evolvable enumeration, and the members split cleanly into "a person or policy decided this was fine" versus "the user or the platform performed a remediating action." The table below groups the values that matter for this audit.
| riskDetail value | What actually happened | Typical riskState |
|---|---|---|
| userPassedMFADrivenByRiskBasedPolicy | The sign-in self-remediated because the user passed MFA required by a risk-based policy | remediated |
| userPerformedSecuredPasswordReset | User self-remediated with a secure password reset | remediated |
| userPerformedSecuredPasswordChange | User self-remediated with a secure password change | remediated |
| userChangedPasswordOnPremises | Password changed on-premises reset the risk (if that feature is enabled) | remediated |
| adminDismissedAllRiskForUser | An admin dismissed all risk for the user in one action | dismissed |
| adminDismissedRiskForSignIn | An admin dismissed the risk for a specific sign-in | dismissed |
| m365DAdminDismissedDetection | A Microsoft Defender XDR admin dismissed the detection | dismissed |
| aiConfirmedSigninSafe | AI confirmed the sign-in as safe | dismissed |
Two things are on your side, though, and the script leans on both. First, closing a detection does not erase it: the riskDetection object persists with its original riskLevel (low, medium, high, or hidden) intact, so you can still tell how serious it was when it fired. Second, the reason it closed is right there in riskDetail. Put those together across a time window and you have exactly the re-review list the dashboard will not give you.
How to verify: check one detection by hand before trusting the script
Before running anything tenant-wide, confirm the behaviour on a single detection so you know the property values mean what you think they mean. This is a two-minute manual check and it saves you from misreading the script's output later. You need an Entra ID P2 (or Entra Suite) licence for Identity Protection, and the least-privileged role that can read the risk reports is Global Reader — you do not need any write role to look.
Start in the portal so you can see the split with your own eyes. Open the Risk detections report and note that it lists detections regardless of whether they are still open, while the Risky users report only surfaces the ones still at risk.
Then confirm the same detection through Graph, because the script reads the exact same property values you are about to eyeball. The cmdlet is Get-MgRiskDetection from the Microsoft.Graph.Identity.SignIns module, and reading detections needs only the read-only scope IdentityRiskEvent.Read.All.
That top row is the whole thesis of this post in one line. A high-severity leaked-credentials detection, closed automatically the instant an MFA prompt was satisfied, with not a single admin action recorded against it. If you had only checked the Risky users report, this row does not exist as far as you are concerned. Compare it against what the two reports show for the same account:
When a row like that turns up, the review is not complicated — it is simply the part the automation skipped. Pull the sign-in that generated the detection, check whether the IP address and location are ones the user genuinely uses, and look for a second session or a token replay around the same timestamp. Microsoft's guidance is clear that self-remediation is the sensible first step for the noisy common case, not the final verdict on a high-severity hit; a leaked credential is only truly cleared once you have confirmed the password actually changed and no unauthorised session is still live. The point of the manual pass is to calibrate your eye so you know what each field means before a script shows you fifty of them.
Once you have seen one detection behave this way — present and high in Risk detections, absent from Risky users, closed with a self-remediation reason — you can trust the tenant-wide script to find the rest of them the same way.
The fix: Get-DismissedRiskDetectionAudit.ps1
Opening the Risk detections report and eyeballing every closed row by hand does not scale past a small tenant or a quiet week. The script below does the same read programmatically: it pulls every risk detection in a lookback window, keeps only the ones whose riskState is dismissed or remediated, and prints each one with its original riskLevel and a plain-English translation of the riskDetail that closed it. High-severity closures are sorted to the top, because those are the ones where an automatic or bulk closure is most likely to have buried something real.
Download Get-DismissedRiskDetectionAudit.ps1 from the Imran76Awan/Daily-Tasks repository on GitHub — no sign-in required. It is read-only and cannot change anything in your tenant — but always validate any script in your own environment before you rely on it.
It supports app-only certificate authentication for scheduled, unattended runs (-TenantId, -ClientId, -CertificateThumbprint) and an interactive -UseDeviceCode fallback for a quick manual look. Set the window with -Days (default 30), and add -ExportCsv to drop the findings to a file for a ticket or an audit trail. Here is a first run against a test tenant over the default 30-day window.
The classification logic is intentionally boring: pull the detections, drop anything outside the window, and keep the rows whose state is one of the two "closed automatically or in bulk" values. The severity sort is what makes the output actionable rather than just a data dump.
Note the deliberate choice to key the review priority on the original riskLevel, not the current state. A detection that fired as high and then self-remediated is exactly the thing you want at the top of the list — its current state is reassuring precisely because the closure was automatic, and that reassurance is what you are trying to second-guess.
Proof it worked: a genuinely reviewed tenant returns nothing
Back to the finance account. Running the audit surfaced the auto-remediated leakedCredentials hit, so an analyst did the review the automation skipped: checked the sign-in logs for the Amsterdam IP, confirmed the account did not normally sign in from there, found a matching token replay, and confirmed the user as compromised — a password reset and session revocation followed. The medium unfamiliarFeatures row turned out to be a legitimate new laptop and was confirmed safe on purpose. Once each closed detection has an explicit human decision behind it, a re-run over the same window comes back clean:
Zero findings here is a real all-clear, and that is the difference from where we started. The dashboard's "no risky users" was true but incomplete — it described the queue, not the history. This audit describes the history: every detection that closed itself or was closed in bulk, with the original severity kept in view, so "nothing on the list" now means "every closure was a decision" rather than "nobody looked."
References
- riskDetection resource type (riskState, riskLevel, riskEventType) — Microsoft Graph
- riskDetail enum type (all closure reasons) — Microsoft Graph
- riskyUser resource type (P2 licence requirement) — Microsoft Graph
- Microsoft Graph PowerShell SDK and Microsoft Entra ID Protection — Microsoft Learn
- Get-MgRiskDetection (module + IdentityRiskEvent.Read.All scope) — Microsoft Learn
- Investigate risk with Microsoft Entra ID Protection — Microsoft Learn
- Daniel Bradley (MVP), "Allow On-Premise Password Change to Reset User Risk in Microsoft Entra" — ourcloudnetwork.com (self-remediation flips riskState to remediated)
How many high-severity detections have quietly closed themselves in your tenant this month? Run the audit over a 30-day window, look at the HIGH rows, and see whether "no risky users" was ever the same thing as "nothing to investigate." I would genuinely like to hear what turns up.