Intune Deployments (Preview): Ring-Based Rollouts, Finally

Believe it or not, despite my constant barrage of YouTube content, I still receive requests for written blogs on certain topics. And while we’re talking about “believe or not”, Microsoft has finally given us the ability to roll out apps and policies in rings on a schedule in Intune. The feature, now in public preview, is called Deployment, and it changes the way I would stage almost every app and policy going forward.

Check out the video here: New in Intune: Roll Out Apps and Policies in Rings with Deployments (Preview)

What are Deployments?

Deployments lets you deliver an Intune app or policy to devices in stages, called rings, that activate on a date and time you set. You’ll find it in the Intune admin center under Devices > Manage devices > Deployments.

There are two pieces, and the difference between them matters:

  • Deployment plans are reusable templates. A plan holds your rings, the groups and assignment filters in each ring, any exclude groups, and the wait time between rings. It never contains the app or policy itself (more on this in Part 2).
  • Deployments are the execution. You pick one payload (an app or a policy), attach a plan or build rings one time, set a start date, and Intune runs it.
Intune admin center. Deployments landing page.

How we do it today

Right now, a staged rollout in Intune is a routine you run by hand. It usually looks something like this:

  1. Assign the app or policy to a pilot group.
  2. Wait a few days and watch for tickets.
  3. Remember to come back.
  4. Add the next group to the assignment.
  5. Wait again.
  6. Eventually switch the assignment to All devices.

It works. But it depends entirely on someone remembering step 3, and every admin does it a little differently. One person’s pilot is IT only, another’s is IT plus a few friendly finance users, and the timing between stages is whatever fit that week. Some of us have tried to approximate rings with availability and deadline dates, and plenty of teams pay for a third-party tool to do it for them.

The real problem isn’t effort. It’s that the rollout pattern lives in someone’s head instead of in Intune.

Why this is a game-changer

Deployments move the rollout pattern out of your head and into Intune, where it runs on a timer and looks the same every time.

  • Standardization. You build your ring structure once as a plan, and every app or policy you roll out follows it. Pilot means the same thing on Monday as it did last month.
  • It runs without you. Each ring activates on schedule. Its groups are added to the payload’s assignments, and earlier rings stay targeted, so the rollout grows in stages instead of jumping straight to everyone.
  • You can stop it. A deployment can be paused, resumed, or canceled mid-flight if the pilot ring starts lighting up the service desk.
  • Delegation actually makes sense. Plans support scope tags and have their own permissions. Running a deployment needs Read and Assign on the payload’s category (Mobile apps or Device configurations), so whoever owns the app can run its rollout.
Intune deployments ring diagram

Rings add up as they go, and the final All devices ring replaces the earlier group assignments while keeping your exclusions.

Personally, I can’t begin to count the number of times this would have been a life saver. One scenario that comes to mind more than any is policy migration. I see this a lot in environments where I’m…cleaning up (for lack of a better term). We’re consolidating policy, moving devices to new groups, and ultimately need a way to push something to devices without accidentally bricking the whole fleet.

What used to add up to many hours of manual effort can now simply be designed and monitored.

What’s available right now

The public preview is (big surprise) Windows only and works with four types of payloads.

PlatformCategorySupported payloads
Windows 10 and laterAppsWin32 apps, Enterprise App Catalog apps
Windows 10 and laterPoliciesSettings catalog, endpoint security policies

Here are a few other stipulations:

  • Apps support the Required assignment intent only; no Available or Uninstall yet.
  • Automatic update for Enterprise App Catalog apps doesn’t work. You’ll have to continue to use supersedence.
  • Payloads that already have assignments will not be affected. The deployment rings are “in addition to” the current assignment. In the future, I would definitely consider consolidating all assignments through Deployments.

For updates on these along with the rest of the documentation, check the official Microsoft Learn overview page.

Rolling it out

Alright- enough talk. Let’s set it up!

How to create a Deployment

Before you start, make sure the app or policy exists in Intune already. It can’t be in another scheduled or active deployment, and every ring needs at least one group.

  1. In the Intune admin center, go to Devices > Manage devices > Deployments and select Create.
  2. On Basics, enter a name and description. Name it for the payload, like “Chrome 141 rollout.”
  3. On Payload selection, choose App or Device configuration, select Add payload, pick one item, and select Select payload. Then select Next.
  4. On Deployment schedule, select Add rings. (You’ll also see Load deployment plans here. That’s the reusable version, and it gets its own post in Part 2.)
  5. In Manage rings, name the first ring and set its start date and start time.
  6. Select Add ring for each additional ring and set the interval in days and hours. The minimum is one hour. Select Save.
  7. Add at least one group to each ring. Usually it’s something like the following:
    • Ring 1: IT
    • Ring 2: early adopters
    • Ring 3: broad business groups
    • Ring 4: All devices

Autopatch groups work great here!

  1. Optionally add Exclude groups. These apply to every ring.
  2. Select Next, then Review + create, then Create.

Once it starts, open the payload’s Assignments and watch them change as each ring activates. That’s it; Intune is doing the assignment edits you used to do on a calendar reminder.

Active Deployment rings

What’s next?

This is the first version of the feature, and it already covers the two things I stage most: Win32 apps and settings catalog policies. It’s not hard to imagine platform scripts, remediations, compliance policies, and more, showing up to this party. And that’s just Windows. I’m sure there are plenty of folks who wouldn’t say no to this on Mac, and even mobile platforms.

So to wrap things up, pick your payload, build the rings, set a start time, and let the schedule do the assignment edits for you. In Part 2, we’ll turn those rings into a reusable deployment plan. Start with something low risk, watch how the assignments change, and get comfortable with pause before you need it.

If you want to see the whole thing built live, including the whiteboard breakdown, the video is at the top of this post.

Microsoft Entra Token Protection for Browsers: Same Key, Wrong Car

My first car had a key you could copy at the hardware store for about four dollars. No verification. No questions. If the key fit, the car assumed you belonged there.

Modern key fobs are smarter. The fob and the car are paired. A signal recorded last Tuesday should not unlock the car on Thursday because the car is checking more than whether the signal looks valid. It also wants proof that the signal came from the fob it trusts.

That is the job Microsoft Entra Token Protection is trying to do for sign-in sessions.

A stolen session token can sometimes let an attacker skip the annoying parts of the attack, including the password and another MFA prompt. Token Protection adds proof that the session belongs to the device where it was created. Steal the token and replay it somewhere else, and the device binding does not come along for the ride.

That sounds like a control I would want everywhere.

Not yet.

This post focuses specifically on Token Protection for web applications that access Azure Resource Manager. The browser feature is still a preview, the scope is narrow, and its blast radius is wider than the list of supported applications makes it appear.

It protects one Conditional Access resource and supports five web applications. At the same time, it can block other applications that also reach Azure Resource Manager.

In other words, this is not another checkbox to enable on a Friday afternoon before heading home.

Start With the Question Token Protection Answers

Token Protection is a Conditional Access session control. When a supported device registers with Microsoft Entra ID, its Primary Refresh Token is cryptographically bound to that device. When a protected application requests access, Entra checks whether the sign-in session is using a device-bound token.

If an attacker steals that token and tries to replay it from another device, the binding cannot be satisfied.

Token Protection sits beside the controls you probably already use, but it answers a different question:

ControlQuestion it answers
Require compliant deviceDoes Intune consider this device compliant?
Require hybrid joined deviceDoes the device have the required identity state?
Authentication strengthDid the user authenticate with an approved method?
Defender for Endpoint riskIs the device risk within tolerance?
Token ProtectionIs this sign-in session bound to the device that created it?

These controls are related. They are not interchangeable. Token Protection does not replace device compliance, phishing-resistant authentication, or device risk. It adds another layer for the applications and sign-in paths that support it.

Token Protection proves where the session belongs. It does not prove everything else about the device or user is safe.

Treat Browser Protection as Its Own Deployment

Token Protection for native applications is already generally available on supported platforms. On Windows, supported resources include Exchange Online, SharePoint Online, Microsoft Teams, Azure Virtual Desktop, and Windows 365. Microsoft documents the supported native applications and device requirements in the Windows deployment guide.

The browser scenario is a separate job. It has its own:

  • Target resource
  • Client app condition
  • Operating system requirements
  • Browser extension requirement
  • Device configuration
  • Supported application list
  • Failure modes

Keep the native and browser deployments in separate Conditional Access policies. Combining them saves you one policy name and costs you clarity everywhere else. Troubleshooting becomes harder, rollback becomes less obvious, and the sign-in logs are more difficult to explain.

Microsoft recommends deploying Token Protection for native applications first, including enforcement for at least a pilot group, before testing the browser preview. That order makes sense. You should know what a bound session looks like in your sign-in logs before adding browser extensions, brokered authentication, and Azure Resource Manager scope to the puzzle.

Native Token Protection and browser Token Protection belong in separate Conditional Access policies.

Read the Azure Resource Manager Scope Literally

The browser preview protects Azure Resource Manager, configured in Conditional Access as Windows Azure Service Management API.

Only these five web applications are currently supported:

  • Azure portal
  • Microsoft Intune admin center
  • Microsoft Entra admin center
  • Microsoft Engage Center
  • Microsoft Engage Hub

That short list is easy to read past. Do not.

When the policy is enforced, other web applications that access Azure Resource Manager are blocked. Microsoft lists several examples:

  • Microsoft 365 Security and Compliance Center
  • Microsoft AppSource
  • Azure Data Factory
  • Azure AI Studio
  • Azure Synapse Studio
  • Microsoft Power BI
  • Microsoft Developer Portal
  • Azure OpenAI Studio
  • Power Platform admin center

Microsoft also states that the list is not exhaustive. That matters more than the neat five-item supported list above it.

This policy does not protect five applications and quietly ignore everything else. It places a wall around browser access to Azure Resource Manager, then opens five supported doors. Anything using another door can run face-first into the wall.

Do not target All resources. Do not start with a broad admin population. Before creating the pilot, identify who accesses Azure Resource Manager through a browser and which applications they use. Your Power BI users and Azure administrators may be the same people. So may your Power Platform admins, developers, and security team.

Target the resource Microsoft documents, not the resource scope that merely sounds more secure.

Get the Browser and Broker Ready First

The feature requires Microsoft Entra ID P1 licensing. Most of the real preparation happens on the device.

Windows

Windows devices need all of the following:

  • Windows 11 build 26100.8246, 26200.8246, or later
  • Microsoft Edge or Google Chrome
  • Microsoft Entra joined, hybrid joined, or registered state
  • Microsoft Single Sign-On browser extension installed and enabled
  • Platform authentication enabled through the registry

Use this registry value:

[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\BrowserCore]
"EnablePlatformAuth"=dword:00000001

Important: EnablePlatformAuth is a requirement for this browser preview. It is not a prerequisite for native application Token Protection.

macOS

For macOS, the device must be MDM managed. Microsoft Company Portal acts as the authentication broker. Hardware-backed registration must be enabled through either the Microsoft Enterprise SSO plug-in or Platform SSO. Edge and Chrome also require the Microsoft Single Sign-On browser extension.

Once the prerequisites are in place, authentication for supported web apps is handed from the browser to the platform authentication broker. That brokered flow is what allows the browser session to use a device-bound token.

The change is not immediate. Microsoft says to allow at least 24 hours after deploying the registry value, extension, or Platform SSO profile before trusting report-only results. If you check too early, your readiness report may simply be a list of devices that have not finished switching to broker-based authentication.

During the preview, some users may briefly see a Signing you in… message in the Azure portal stating that a new window is opening. No new window appears, no action is required, and the sign-in completes automatically. Tell the pilot group before a harmless message becomes a very serious-looking support ticket.

Prove the Extension Is Really There

That works for a small pilot, but asking every user to install an extension is not much of an enterprise deployment story.

Microsoft’s browser preview guide documents a manual installation process. Chrome installs the Microsoft Single Sign-On extension from the Chrome Web Store. Edge requires the user to open edge://extensions, enable Allow extensions from other stores, and then install the same extension.

That may work for a small pilot. It is not much of an enterprise deployment story.

The Edge policy named ControlDefaultStateOfAllowExtensionFromOtherStoresSettingEnabled can turn on the default for Allow extensions from other stores. However, Microsoft documents that policy as recommended-only. Users can override it, and it may not work if a user previously enabled the setting and then turned it off.

You may choose to test enterprise extension deployment policies for Edge or Chrome, but the Token Protection preview guide does not currently document that as its deployment method. Validate what the browser actually received before building your rollout around it.

Test at least these scenarios:

  • A new browser profile
  • An existing profile where the user has never changed the other-store setting
  • A profile where the user previously disabled the setting
  • Edge and Chrome, if both are supported in your environment

Then open the browser and confirm the extension is present and enabled. A green Intune deployment status does not prove that the browser is ready. It only proves that Intune is happy with the part it can see.

Build the Policy, Then Leave It in Report-Only

Sign in to the Microsoft Entra admin center as at least a Conditional Access Administrator. Then browse to Entra ID > Conditional Access > Policies > New policy.

I would use a name that tells the next administrator exactly what the policy targets and whether it is still a pilot:

CA-PILOT-Session-RequireTokenProtection-ARM-Browser

Configure the policy as follows:

1. Under Assignments > Users, include only your pilot group. Exclude emergency access accounts.

2. Under Target resources > Resources (formerly cloud apps) > Include > Select resources, select Windows Azure Service Management API (Read the warning below before you go looking for it.)

3. Under Conditions > Device platforms, set Configure to Yes and include Windows, macOS, or both.

4. Under Conditions > Client apps, set Configure to Yes and include Browser. Leave Mobile apps and desktop clients unchecked.

5. Under Access controls > Session, select Require token protection for sign-in sessions.

6. Set Enable policy to Report-only, then create the policy.

Step 2 needs a warning, because I went looking for the resource Microsoft names in the deployment guide and it was not there.

The guide says to select Windows Azure Service Management API. The resource you actually want is application ID 797f4846-ba00-4fd7-ba43-dac1f8f63013, which has been renamed twice. It started life as Microsoft Azure Management, became Windows Azure Service Management API, and now appears in the picker as Azure Resource Manager. The Token Protection guide is still using the middle name.

Make the Sign-In Logs Prove It

Creating a report-only policy is the start of the test, not the result. The useful evidence is in the sign-in logs.

Go to Entra ID > Monitoring & health > Sign-in logs.

Then:

1. Add the Token Protection – Sign-in session status code column.

2. Filter Resource to Azure Resource Manager.

3. Set Client app to Browser.

4. Open a sign-in event.

5. Review the Conditional Access or Report-only tab and select your Token Protection policy.

6. Under Session controls, confirm whether the requirement was satisfied.

7. Open Basic info and review Token Protection – Sign-in Session.

The log also exposes a tokenProtectionStatusDetails property:

"tokenProtectionStatusDetails": {
  "signInSessionStatus": "bound | unbound",
  "signInSessionStatusCode": 1008
}

A sign-in can contain multiple requests. Every request must be bound for the Token Protection requirement to be satisfied. One bound request does not prove the whole sign-in passed. Filter by correlation ID and review the complete set.

This is where a quick pilot can fool you. One successful Azure portal sign-in from one freshly built device proves that one path worked once. It does not tell you what happens when the same administrator opens Power BI, switches browsers, signs in from a Cloud PC, or returns with an older browser profile.

The policy state tells you what Entra was asked to do. The sign-in logs tell you what actually happened.

Let the Status Code Lead the Investigation

The status code is your starting point. It keeps you from treating every unbound session as the same problem.

CodeMeaningNext step
1002No Microsoft Entra device stateRegister or join the device
1003Device registration does not satisfy Token Protection requirementsOn Windows, check for an unsupported registration type or stale registration credentials. On macOS, the user may complete a one-time registration upgrade
1004Registration is not hardware-backed. macOS onlyUser completes a one-time registration upgrade
1005Unbound for an unspecified reasonInvestigate using the correlation ID
1006Unsupported operating system versionUpgrade to a supported build
1007Registration is not hardware-backed and the signed-in user is not the registered device ownerReregister the device or have the registered owner complete the upgrade
1008The client did not use a platform authentication brokerVerify the supported browser, extension, broker, and EnablePlatformAuth configuration

Codes 1002 and 1008 are common during browser onboarding. Code 1008 usually points to a missing or disabled extension, missing platform authentication, an unsupported browser such as Firefox or Safari, or an application that does not support Token Protection.

On macOS, codes 1003 and 1004 can be self-remediable. The user is prompted to authenticate and complete a one-time device registration upgrade after enforcement. Those users may be eligible for enforcement even though their current report-only events appear unbound.

That distinction changes the readiness calculation. The useful number is not only the percentage of sessions bound today. It also includes macOS sessions that can upgrade through the supported sign-in flow.

Find the Cloud PCs and Virtual Desktops Before They Find You

This limitation is easy to miss because two true statements sit uncomfortably close together.

Windows 365 and Azure Virtual Desktop are supported resources for native Token Protection. However, Microsoft Entra joined Windows 365 Cloud PCs and Microsoft Entra joined Azure Virtual Desktop session hosts use unsupported device registration types for Token Protection. Those devices may appear with status code 1003.

Other unsupported Windows registration scenarios include:

  • Bulk-enrolled Windows devices
  • Microsoft Entra joined Power Automate hosted machine groups
  • Windows Autopilot self-deploying mode devices
  • Azure virtual machines enabled for Microsoft Entra authentication through the VM extension

Microsoft recommends using a device filter to exclude impacted device types during onboarding.

Enforce in Waves You Can Explain

There is no shortcut between report-only and enforcement. Review the logs, confirm the targeted users and devices are ready, and then change Enable policy from Report-only to On.

For the rollout itself:

  • Keep report-only enabled long enough to capture normal work patterns. Five minutes with a test account proves very little.
  • Enforce the policy for confirmed-ready pilot users first.
  • Expand by persona or administrative function, not by randomly adding hundreds of users.
  • Document the rollback. Returning the policy to report-only should be a known and rehearsed step.
  • Watch support tickets and sign-in failures. A user who quietly switches browsers may never report the original failure.
  • Recheck unsupported applications before every expansion wave.

For a preview control that can interrupt administrative access, rollout discipline matters more than speed. Nobody remembers that a security feature was enabled quickly. They absolutely remember being locked out of the admin center during an incident.

Keep the Promise Narrow

Token Protection does not eliminate token theft. It does not stop every form of cookie theft or adversary-in-the-middle phishing. It does not replace phishing-resistant authentication. It does not protect every SaaS browser session. It does not make unsupported applications or clients compatible. Browser support is not available on iOS or iPadOS.

It reduces a specific risk. The replay of a stolen sign-in session token from a different device, against supported resources, using supported applications and platforms.

Turn the Key Only After You Test the Door

Pairing a key fob to a car is not something you confirm by looking at the paperwork. You press the button and the doors unlock, or they do not.

Token Protection works the same way. A Conditional Access policy sitting in report-only mode is not proof that the environment is ready. A bound sign-in session from the user, device, browser, and application you intended to protect is the proof.

Start with native Token Protection. Inventory browser access to Azure Resource Manager. Deploy the browser prerequisites. Wait the full 24 hours. Run the policy in report-only mode long enough to capture real work. Read the status codes, test unsupported workflows, and keep the blast radius small.

Then enforce it for the users you have actually proven are ready.

Global Secure Access in Sentinel, Part 2: From Dashboards to Detections

Back in March, I showed you how to install the deadbolt: GSA file policies plus Purview DLP, blocking sensitive uploads at the network layer. A deadbolt is great. A deadbolt also has one glaring flaw. It cannot tell you who tried the handle, how many times, or whether they walked around back and tried the window next.

That’s what a doorbell camera is for.

In Part 1 of this series, we cleaned out the junk drawer: GSA logs streaming into Microsoft Sentinel, the official solution installed, and a workbook tab showing traffic, blocks, and policy hits. Today we finish the job. We’ll track down where your Purview DLP evidence actually lands (spoiler: it’s not one place), add a DLP tab to the workbook, correlate denied uploads with Defender device risk, and turn the patterns we find into analytics rules. In other words, we’re mounting the camera above the deadbolt.

What You’ll Need This Time

Everything from Part 1 carries over. On top of that:

  • GSA file policies and a Purview Inline web traffic DLP policy already configured. The deadbolt post walks through every step, so I won’t repeat it here.
  • Your Sentinel workspace onboarded to the Defender portal, so advanced hunting can see both your Sentinel tables and the Defender XDR tables in one place.
  • Optionally, the Microsoft Defender XDR data connector with event streaming enabled for DeviceInfo, AlertInfo, and AlertEvidence. More on why in the correlation section.

Note: The GSA and Purview network integration is still in preview as of this writing, with general availability targeted for this fall. Meanwhile, Microsoft is rolling out an extension of this integration (MC1419797) that inspects sensitive text and prompts at the network layer, not just files.

Where the Evidence Actually Lands

Here’s the part that trips people up. When a user’s upload gets blocked, the evidence doesn’t land in one tidy table. It scatters across a few places, and each one answers a different question.

SignalWhere it livesWhat you need
The network deny itselfNetworkAccessTraffic in your Sentinel workspace, with PolicyName and RuleName populated, assuming your filtering policy is linked to a security profile as covered in Part 1Part 1 diagnostic settings
DLP alerts and incidentsPurview Alerts, Defender XDR incidents, and the SecurityAlert table in SentinelDefender XDR incident integration (alert and incident ingestion is free)
Policy hit details in Activity ExplorerPurview portal, filtered by enforcement plane = networkNothing extra
Purview activity events for huntingDataSecurityEvents (preview) in advanced huntingInsider Risk Management alert sharing with Defender XDR opted in

That last row deserves a callout, and a warning. The DataSecurityEvents table reads like the richest hunting source here, with columns named DlpPolicyMatchInfo, SensitiveInfoTypeInfo, TargetUrlDomain, and DlpPolicyEnforcementMode. Microsoft populates it through Insider Risk Management, and the documented prerequisite is opting in to share insider risk data with Defender XDR. In my tenant that setting was already on and the table was still empty, which cost me an evening. The reason sits one layer further down. Insider Risk does not score a user until a triggering event fires, so all three of my policies sat at zero users in scope and the table had never received a record. If yours comes back empty, open Purview, then Insider Risk Management, then Policies, and read the Users in scope column before you assume the integration is broken. Zero there explains everything, and Start scoring activity for users is the button that fixes it.

Confirm the sources exist before you build

Part 1 spent a whole step on this and the lesson carries over. A query against a table that has never received data returns nothing without complaining, so confirm the sources are there before you build three tabs on top of them.

union isfuzzy=true
    DataSecurityEvents,
    SecurityAlert,
    DeviceInfo,
    AlertInfo
| summarize Events = count() by Type
| order by Events desc

Anything missing from that list is a tab you cannot build yet. No DataSecurityEvents rows means Insider Risk Management, either the sharing toggle or, more likely, no user in scope. No AlertInfo or DeviceInfo means the Defender XDR connector, or that you are running this in a workspace query window instead of advanced hunting. SecurityAlert present with no DLP rows in it means the incident integration is wired up and simply has not fired yet, which on a quiet tenant is the right answer rather than a broken query.

Tab 2: The DLP View

Back to our workbook from Part 1. Add a new tab and let’s answer the question the deadbolt can’t: who keeps trying the door?

Denies by file policy

NetworkAccessTraffic
| where TimeGenerated > ago(7d)
| where Action =~ "Block"
| where isnotempty(PolicyName)
| summarize Denies = count() by PolicyName, RuleName, UserPrincipalName, DestinationFqdn
| top 20 by Denies

This is the workhorse. It shows which file policy is doing the heavy lifting, which rule inside it fired, and who keeps hitting it. A policy with zero hits after two weeks either means your users are angels or your CA scoping missed them. Trust me on this one, it’s usually the scoping.

Repeat offenders over time

NetworkAccessTraffic
| where TimeGenerated > ago(7d)
| where Action =~ "Block"
| where isnotempty(PolicyName)
| summarize Denies = count() by bin(TimeGenerated, 1d), UserPrincipalName
| order by TimeGenerated asc

Render as a timechart. One deny is a mistake. The same user hitting the same block daily is either broken workflow, missing training, or someone probing for a way around your policy. All three deserve a conversation, just very different conversations.

Audit versus block mix

This one runs in advanced hunting in the Defender portal, since DataSecurityEvents lives on that side. Read the next few paragraphs before you build a tab on it:

DataSecurityEvents
| where Timestamp > ago(7d)
| extend Mode = case(
    DlpPolicyEnforcementMode == 0, "None",
    DlpPolicyEnforcementMode == 1, "Audit",
    DlpPolicyEnforcementMode == 2, "Warn",
    DlpPolicyEnforcementMode == 3, "Warn and bypass",
    DlpPolicyEnforcementMode == 4, "Block",
    DlpPolicyEnforcementMode == 5, "Allow",
    "Unknown")
| summarize Events = count(), Users = dcount(AccountUpn) by Mode, ActionType
| order by Events desc

The DlpPolicyEnforcementMode values are 0 (None), 1 (Audit), 2 (Warn), 3 (Warn and bypass), 4 (Block), and 5 (Allow), which is why the query spells them out rather than leaving you to decode integers in a chart. A policy showing heavy audit volume and zero blocks is a policy waiting for its production cutover decision, and this is how you make that case with data instead of vibes.

Now the warning. Once my table finally filled, it held 1,466 rows and not one of them was a network upload. Every ActionType was endpoint file activity: file deleted on endpoint, sensitive file read, file rename, removable media mounted, file synced from OneDrive. DlpPolicyMatchInfo was empty on all 1,466. So was TargetUrlDomain, which is the column I had planned the whole tab around. The first version of this query filtered on isnotempty(DlpPolicyMatchInfo) and grouped by TargetUrlDomain, and it returned zero rows against a table with fourteen hundred records in it.

The lesson generalizes past this one table. A documented column is a promise about schema, not a promise that your data will fill it. DataSecurityEvents is fed by Insider Risk Management, Insider Risk watches endpoints, and the GSA network plane is a different pipeline that lands in NetworkAccessTraffic and SecurityAlert instead. Build the tab on DlpPolicyEnforcementMode and ActionType, which are populated, and get your network story from Tab 2 where it actually lives.

DLP alert volume in Sentinel

SecurityAlert
| where TimeGenerated > ago(7d)
| summarize Alerts = count() by ProductName, AlertName, AlertSeverity
| order by Alerts desc
| take 10

Since alert and incident ingestion through the Defender XDR connector is free, this view costs you nothing extra and gives the SOC a Sentinel-native count of DLP alert activity. Notice there is no ProductName filter on that query, and that is deliberate. I first wrote it as ProductName has “Data Loss Prevention”, got an empty chart, and spent an evening convinced the integration was broken. It was not. Nothing had fired yet, and an empty result from a hardcoded filter looks exactly like an empty result from a dead pipeline. The day the file policy actually blocked something, the rows appeared under Microsoft Data Loss Prevention. Same trap as the Action column in Part 1, different column. Run SecurityAlert | distinct ProductName in your own workspace and filter on what is actually there rather than on what a blog post told you to expect.

Tying the block to the alert

Two posts ago the deadbolt went on the door. Everything above tells you it fired. This is the part where the camera and the deadbolt finally point at the same person.

When my test upload finally got stopped, the evidence arrived in two pieces. Global Secure Access logged nineteen blocked rows against the file policy, carrying the rule name, the user, the destination, and a TlsAction of Intercepted. Purview raised a separate High severity alert, categorized as Exfiltration, that knew a policy had been violated and nothing at all about the network path. Neither one is the whole story. Until you join them, an analyst is reading two grids and guessing they belong together.

Worth pausing on what this looks like from the other side of the screen, because it is not a security message and it does not mention policy, Purview, or Global Secure Access. The user gets a network error.

That red banner is the entire user-facing story of a blocked upload. ChatGPT tried to push the file to files.oaiusercontent.com, the request never completed, and the app reported the only thing it could see, which is that the network refused it. Note who it tells the user to go talk to. That is your help desk, and this is the ticket they are going to open, worded exactly like that.

This matters for two reasons. First, if you roll out a file policy without telling anyone, the failure mode your users experience is an unexplained upload error on a site they use every day, and they will retry it several times before they call. Those retries are the repeat hits in the chart above, not malice. Second, the FQDN in the error message is the same one sitting in DestinationFqdn on the blocked rows, which is what lets a help desk analyst close the loop in one query instead of escalating. Paste the host from the user’s screenshot into the Tab 2 grid and the policy name comes back with it.

Here is the join. It runs entirely against workspace tables, so unlike the Tab 3 queries below it needs nothing from advanced hunting and nothing from the Defender XDR connector:

let DlpAlerts = SecurityAlert
| where TimeGenerated > ago(1d)
| where ProductName has "Data Loss Prevention"
| mv-expand E = todynamic(Entities)
| where tostring(E.Type) == "account"
| extend AlertUpn = tolower(strcat(tostring(E.Name), "@", tostring(E.UPNSuffix)))
| project AlertTime = TimeGenerated, SystemAlertId, AlertName, AlertSeverity, AlertUpn;
NetworkAccessTraffic
| where TimeGenerated > ago(1d)
| where Action =~ "Block"
| where PolicyName has "Confidential"
| extend Upn = tolower(UserPrincipalName)
| join kind=inner DlpAlerts on $left.Upn == $right.AlertUpn
| where AlertTime between (TimeGenerated .. TimeGenerated + 30m)
| summarize
    Blocks = dcount(TransactionId),
    Alerts = dcount(SystemAlertId),
    Destinations = make_set(DestinationFqdn, 5),
    FirstBlock = min(TimeGenerated),
    LastAlert = max(AlertTime)
    by UserPrincipalName, PolicyName, RuleName, AlertName

Swap Confidential for whatever your own file policy is called. Three things in that query are load bearing, and I got all three wrong on the first pass.

The identity is buried in Entities. CompromisedEntity came back empty on every DLP alert I looked at, which is what sent me down a dead end the first time and nearly cost this section its join. The account is in the Entities column instead, as JSON, so you have to mv-expand it and rebuild the UPN from Name and UPNSuffix. Nine alerts, nine account entities, every one populated. It is there. It just is not where you would look first.

Scoped properly, the answer is one row. One user, one file policy, nineteen blocked transactions against an OpenAI upload endpoint, and ten Purview alerts arriving in the minutes afterward. That row is the thing the deadbolt post could not give you: who tried the handle, which policy stopped them, and how many times they tried before giving up.

One honest limit before you build on this. The alert carried an account entity and nothing else. FileName and RemoteUrl came back empty, so the alert tells you who and which policy, not which file. The file name lives in Purview Activity Explorer, and it should also surface in DataSecurityEvents once a user is actually in Insider Risk scope, which is one more reason that table is worth chasing.

Tab 3: Correlation, or Why This Beats a Pretty Dashboard

A blocked upload from a healthy, patched laptop is a Tuesday. A blocked upload from a device Defender already flags as high exposure is a different animal entirely. Connecting those two signals is where this workbook earns its keep.

Denied traffic from exposed devices

The old trick of joining on UserPrincipalName is fragile, since DeviceInfo doesn’t carry a UPN column. The better join key is hiding in plain sight: GSA traffic logs carry the device ID, and DeviceInfo has AadDeviceId.

let ExposedDevices = DeviceInfo
| where Timestamp > ago(7d)
| where isnotempty(AadDeviceId)
| summarize arg_max(Timestamp, ExposureLevel, DeviceName) by AadDeviceId
| where ExposureLevel in ("Medium", "High");
NetworkAccessTraffic
| where TimeGenerated > ago(7d)
| where Action =~ "Block"
| join kind=inner ExposedDevices on $left.DeviceId == $right.AadDeviceId
| summarize Denies = count() by DeviceName, UserPrincipalName, DestinationFqdn, ExposureLevel
| top 20 by Denies

The ExposureLevel column comes straight from Defender Vulnerability Management (Low, Medium, High). A high-exposure device repeatedly bouncing off your sensitive upload policy is exactly the row an analyst should see first thing in the morning.

Side note….. do not judge me for having a High Exposure Level in my lab. Trust me, I’m not a fan.

Vulnerable devices touching notable destinations

let VulnerableDevices = DeviceTvmSoftwareVulnerabilities
| summarize Vulns = dcount(CveId) by DeviceId
| where Vulns > 50;
let DeviceMap = DeviceInfo
| where isnotempty(AadDeviceId)
| summarize arg_max(Timestamp, AadDeviceId) by DeviceId, DeviceName;
NetworkAccessTraffic
| where TimeGenerated > ago(7d)
| join kind=inner (
    VulnerableDevices
    | join kind=inner DeviceMap on DeviceId
) on $left.DeviceId == $right.AadDeviceId
| summarize Sessions = count() by DeviceName, DestinationFqdn
| top 20 by Sessions

Weak device posture plus repeated traffic to destinations you care about is a risky combination worth watching before it becomes an incident. Tune the vulnerability threshold to your environment; 50 is a starting point, not gospel.

Defender alerts landing near GSA denies

let GsaDenies = NetworkAccessTraffic
| where TimeGenerated > ago(1d)
| where Action =~ "Block"
| project DenyTime = TimeGenerated, UserPrincipalName, DestinationFqdn;
AlertInfo
| where Timestamp > ago(1d)
| join kind=inner (
    AlertEvidence
    | where EntityType == "User"
    | project AlertId, AccountUpn
) on AlertId
| join kind=inner GsaDenies on $left.AccountUpn == $right.UserPrincipalName
| where abs(datetime_diff("minute", Timestamp, DenyTime)) < 30
| summarize Alerts = dcount(AlertId), Denies = count() by AccountUpn, Title, DestinationFqdn
| top 20 by Denies

An endpoint alert and a blocked upload from the same user inside a 30-minute window is the kind of overlap that turns two shrugs into one investigation. Those clusters are your strongest candidates for the analytics rules coming up next. Expect this one to come back empty most days, and on a quiet tenant that is the correct answer rather than a broken query. It needs an endpoint alert and a network block landing on the same identity inside the same half hour, which is rare by design. In my lab it stayed empty until I went looking for a way to make both fire at once.

From Workbook to Analytics Rules

Dashboards are for humans who happen to be looking. Analytics rules are for 2 AM. Once the workbook shows you which patterns matter in your environment, promote them.

Where analytics rules live, and how to make one

Quick detour, because Part 1 never needed this blade and the rest of this section assumes you can find it. Everything here is in the Defender portal, the same place you have been running the hunting queries. In the left navigation, open Microsoft Sentinel, then Configuration, then Analytics.

Three tabs across the top. Active rules is what is running right now. Rule templates is the catalog that content installs drop into your workspace. Anomalies is the machine learning baselines, which write to their own table and do not raise incidents on their own, so leave those alone for today.

Those three GSA rules did not come from me. They arrived with the Global Secure Access solution you installed in Part 1, which is what the Source name column is telling you. Installing content gives you templates, and some solutions also create active rules for you. Worth knowing which is which before you go build something that already exists.

Which brings up the two ways into this. If a template already covers what you want, go to Rule templates, find it, open it, and choose Create rule. The wizard opens pre-filled with the vendor’s query and MITRE mapping, and you tune from there. My tenant has 220 templates sitting in that tab, including the GSA one I lean on in Rule 3 below:

If nothing fits, which is the case for both rules below, use Create, then Scheduled query rule, and build it yourself. That opens a five tab wizard, and the settings tables I give for each rule map onto those tabs like this:

Wizard tabWhat you fill in
GeneralName, description, severity, MITRE tactic and technique, and whether the rule starts enabled.
Set rule logicThe KQL itself, entity mapping, custom details, then query scheduling, alert threshold, event grouping, and suppression.
Incident settingsWhether alerts become incidents, and how alerts group together into one incident.
Automated responseAutomation rules that fire on the incident. Skip it for now.
Review and createValidation. A red mark on a tab means something above it is wrong.

Four fields on that second tab do most of the work, and they are the ones people skip. Map entities is what turns a row of text into a clickable account or host in the investigation graph, and a rule without it is a notification rather than a detection. Run query every and Lookup data from the last are your frequency and lookback, and the lookback has to be at least as long as the frequency or you will drop events between runs. Generate alert when number of query results is the threshold, which stays at 0 when the query already carries its own threshold the way both of mine do. And Test with current data simulates fifty runs against real data and shows you the alert volume before you inflict it on anyone.

One field trips up almost everyone the first time. Your query has to return a column called TimeGenerated, because that is the reference point the scheduler uses for the lookback window. A summarize throws that column away, which is why both rules below end with extend TimeGenerated = LastSeen. Leave it off and the rule saves fine, then never fires.

Rule 1: Repeated denied sensitive uploads by one user

NetworkAccessTraffic
| where TimeGenerated > ago(1d)
| where Action =~ "Block"
| where isnotempty(PolicyName)
| summarize
    Denies = count(),
    Destinations = make_set(DestinationFqdn, 10),
    Policies = make_set(PolicyName, 5),
    FirstSeen = min(TimeGenerated),
    LastSeen = max(TimeGenerated)
    by UserPrincipalName
| where Denies >= 5
| extend TimeGenerated = LastSeen
SettingValue
Rule typeScheduled
Frequency / lookbackEvery 1 hour / 1 day
SeverityMedium
Entity mappingAccount: FullName = UserPrincipalName
MITRE ATT&CKExfiltration, T1567
Incident groupingGroup by Account entity

The TimeGenerated = LastSeen alias matters, since scheduled rules use that field as their reference point after a summarize. In addition, group incidents by the Account entity so one persistent user creates one incident instead of flooding your queue with five.

Rule 2: High-exposure device with repeated denies

Take the exposed devices query from Tab 3, tighten it to ExposureLevel == “High”, add a Denies >= 3 threshold with the same FirstSeen/LastSeen pattern, and map both an Account entity (UserPrincipalName) and a Host entity (DeviceName). Severity High, since this is your context-rich signal. One catch: a Sentinel scheduled rule can only query the workspace, so this rule requires DeviceInfo streamed in through the Defender XDR connector. That ingestion cost buys you a detection neither the network layer nor the endpoint layer could produce alone.

Same join as Tab 3, tightened into a rule. Note the switch from Timestamp to TimeGenerated on DeviceInfo, which is the swap the heads-up above was talking about. A scheduled rule reads the workspace, not advanced hunting.

let ExposedDevices = DeviceInfo
| where TimeGenerated > ago(1d)
| where isnotempty(AadDeviceId)
| summarize arg_max(TimeGenerated, ExposureLevel, DeviceName) by AadDeviceId
| where ExposureLevel == "High";
NetworkAccessTraffic
| where TimeGenerated > ago(1d)
| where Action =~ "Block"
| join kind=inner ExposedDevices on $left.DeviceId == $right.AadDeviceId
| summarize
Denies = count(),
Destinations = make_set(DestinationFqdn, 10),
FirstSeen = min(TimeGenerated),
LastSeen = max(TimeGenerated)
by UserPrincipalName, DeviceName, ExposureLevel
| where Denies >= 3
| extend TimeGenerated = LastSeen

Rule 3: Already in the box

Resist the urge to build an IP-anomaly rule from scratch. GSA – Detect Abnormal Deny Rate for Source to Destination IP, from the Part 1 solution install, already learns a five day baseline and does this for you. One thing to check before you enable it: open the template’s query and confirm what it filters Action on. If it is looking for Denied, it will sit at zero forever for the same reason we covered in Part 1. Fix the filter when you create the rule from the template, then tune it and move on.

💡 Tip: Run every new rule with a generous threshold for the first two weeks, then tighten. Starting strict and drowning your SOC in day-one incidents is how detection engineering gets a bad name at your org.

Test It Like You Mean It

Before calling this done, run a repeatable test pass. The deadbolt post covers generating test hits with a sample sensitive file. Here’s the matrix I use:

ScenarioWhat it validatesExpected result
Upload a sensitive test PDF to a targeted AI siteEnd-to-end blockBlocked row in Tab 2 with your policy name, plus a DLP alert
Upload a benign file to an allowed siteBaseline behaviorAllowed traffic, no DLP signal
Repeat the blocked upload five timesRule 1 logicUser surfaces in the repeat offenders chart, Rule 1 fires
Trigger a block from a high-exposure lab deviceCorrelation joinDevice appears in Tab 3 with ExposureLevel populated
Trigger an endpoint alert for the same user within 30 minutes of a blockAlert proximity joinUser and alert title appear together in the third Tab 3 visual
Join the block to the DLP alert on user and timeCross-system correlationOne row per user and policy, with the alerts landing after the blocks

Wrapping Up: Deadbolt, Camera, and a Clean Drawer

Two posts ago, your logs were a junk drawer. One post ago, the drawer had dividers. Today the deadbolt finally has its doorbell camera: a DLP tab showing which policies fire and who keeps testing them, a correlation tab that separates routine denies from denies on devices Defender already distrusts, and analytics rules standing watch while you sleep.

The deadbolt keeps the data in the house. The camera tells you who tried the handle, and now it texts you about it too. That’s the difference between enforcement and visibility, and you need both.

Global Secure Access in Sentinel, Part 1: Logs Are Not a Strategy

Every house has a junk drawer. You know the one. Dead batteries, three takeout menus, a single Allen wrench, and a charger for a phone you sold in 2018. Everything went in because “we might need it later.” Nothing has come out since.

A Log Analytics workspace can turn into the junk drawer of your Microsoft cloud. We flip on diagnostic settings, feel productive for a day, and promise ourselves we’ll build dashboards “when things slow down.” Meanwhile the logs pile up, the ingestion bill grows, and the one night you actually need answers, you’re digging past the takeout menus at 2 AM.

Today we’re cleaning out the drawer. In this post, we’ll stream Global Secure Access (GSA) logs into Microsoft Sentinel, install the official GSA solution from the Content hub, and build a workbook view that answers real questions: who went where, what got blocked, and which policy did the blocking. In Part 2, we’ll bring Microsoft Purview Network Data Security into the picture, correlate GSA blocks with Defender device risk, and turn what the workbook shows us into analytics rules.

If you’re brand new to GSA, start with my post on blocking AI apps with Entra Internet Access, then come back. We’ll wait. In addition, if you want the enforcement side of this story, my recent post on GSA file policies and Purview shows you how to install the deadbolt. This series is about proving the deadbolt actually works.

What You’ll Need

Before we start, make sure you have the following in place:

  • Microsoft Sentinel enabled on a Log Analytics workspace.
  • Global Secure Access deployed with at least one traffic forwarding profile enabled (Microsoft traffic, Internet Access, or Private Access).
  • The Microsoft Entra ID data connector configured in Sentinel.
  • Security Administrator in Entra to configure diagnostic settings, plus Microsoft Sentinel Contributor on the workspace’s resource group to install Content hub solutions.

Nothing exotic here. If GSA is already forwarding traffic in your tenant, you’re most of the way there.

Step 1: Point the Logs at Sentinel

First things first, we need to tell Entra where to send everything. Sign in to the Entra admin center and head to Entra ID > Monitoring & health > Diagnostic settings, then select Add diagnostic setting.

Give it a name that future you will recognize. “diag-setting-1” is NOT that name. Trust me on this one.

In the Logs section, these are the GSA categories and where each one lands in your workspace:

Diagnostic categorySentinel tableWhat it gives you
NetworkAccessTrafficLogsNetworkAccessTrafficTransaction-level detail for every request through GSA: user, device, destination, policy, action
NetworkAccessConnectionEventsNetworkAccessConnectionEventsConnection lifecycle with identity, device, and PoP region context
NetworkAccessGenerativeAIInsightsNetworkAccessGenerativeAIInsightsGenerative AI and MCP activity, including prompt-level visibility (preview)
RemoteNetworkHealthLogsRemoteNetworkHealthLogsIPsec tunnel and BGP session health for remote networks
NetworkAccessAlertsNetworkAccessAlertsGSA’s native security and policy alerts

Select the categories you need, choose Send to Log Analytics workspace under Destination details, pick your Sentinel workspace, and save.

Heads up: NetworkAccessTraffic is transaction-level, which is a polite way of saying it’s chatty. Every HTTP request through GSA becomes a row. Turn on the categories you’ll actually use, then keep an eye on ingestion volume for the first week before you commit to all five everywhere.

Step 2: Install the Global Secure Access Solution

Microsoft ships an official GSA solution in the Sentinel Content hub, and it saves you from starting with a blank canvas. The package includes three workbooks and seven analytics rules.

To install it, sign in to the Defender portal and browse to Microsoft Sentinel > Content management > Content hub. Search for “Global Secure Access,” select the solution, and click Install.

Here’s what comes in the box on the workbook side:

  • Network Traffic Insights. The operational dashboard for GSA traffic across all three forwarding profiles. This one only needs the diagnostic settings from Step 1.
  • Enriched Microsoft 365 logs Workbook. Correlates OfficeActivity audit events with GSA traffic using UniqueTokenId as the join key. This requires the separate Microsoft 365 data connector in Sentinel, since OfficeActivity doesn’t come from Entra diagnostic settings.
  • MCP Servers Dashboard (preview). Visualizes MCP and AI agent traffic from the NetworkAccessGenerativeAIInsights table. If shadow AI is on your radar, and it should be, this is the one you came for. It also has a prerequisite the install won’t mention, which we’ll get to in Step 3.

The seven analytics rules are worth a quick look too:

RuleWhat it catches
GSA – TI Domain EntityGSA destinations matching threat intel domain IOCs
GSA – TI IP EntityDestination IPs matching threat intel IP IOCs
GSA – TI URL EntityDestination URLs matching threat intel URL IOCs
GSA – Detect Abnormal Deny Rate for Source to Destination IPDeny-rate spikes against a learned five-day baseline
GSA – Detect Protocol Changes for Destination PortsProtocol mismatches that suggest tunneling
GSA – Detect Source IP Scanning Multiple Open Ports100+ distinct ports hit in 30 seconds
GSA – Detect Connections Outside Operational HoursConnections before 8 AM or after 6 PM

Installing the solution gives you these as rule templates. You still have to create a rule from each one you want running, so don’t assume you’re covered the moment the install finishes.

Tip: That last rule assumes your company sleeps at night. If you have shift workers or a global footprint, tune the hours before enabling it, or enjoy a fresh batch of incidents every morning at 6:01.

Step 3: Make Sure the Data Actually Showed Up

Sentinel only creates a table once data lands in it. In other words, if you check five minutes after saving the diagnostic setting and see nothing, don’t panic. Give it time.

Once you’ve waited a bit, browse to Microsoft Sentinel > Configuration > Tables in the Defender portal and confirm the GSA tables exist. Only the categories that have actually received data will appear here, so a short list is normal early on.

For a faster sanity check, run this in a query window:

union isfuzzy=true
    NetworkAccessTraffic,
    NetworkAccessConnectionEvents,
    NetworkAccessAlerts,
    RemoteNetworkHealthLogs,
    NetworkAccessGenerativeAIInsights
| where TimeGenerated > ago(24h)
| summarize Events = count() by Type
| order by Events desc

The isfuzzy=true flag keeps the query from failing if one of the tables hasn’t been created yet. Tables that exist show a count, tables that don’t just stay quiet.

One table deserves its own warning, because it behaves differently from every other category in the list. NetworkAccessGenerativeAIInsights is a lake-only table. Microsoft’s table reference lists “Lake-only ingestion: Yes” in the attributes box, and what that means in practice is that a standard Analytics-tier workspace never creates the table at all. Not slowly, and not eventually. The diagnostic category saves happily with the box ticked, the portal gives you no warning, and the table simply never shows up in Configuration > Tables. The MCP Servers Dashboard workbook reads from that table, so it renders empty right along with it.

None of that means GSA missed anything. In my lab it was decrypting and parsing MCP conversations perfectly the whole time, down to the JSON-RPC method per event and the client and server names lifted out of the initialize handshake. All of it was sitting in Entra under Global Secure Access > Monitor > Gen AI Insights logs, current to the second. It just never crossed into Log Analytics.

One query settles which situation you’re in:

Usage
| where TimeGenerated > ago(30d)
| where DataType startswith "NetworkAccess"
| summarize TotalMB = sum(Quantity), Last = max(TimeGenerated) by DataType

If a data type shows bytes there, ingestion is working and you are only waiting on latency. If NetworkAccessGenerativeAIInsights never appears at any volume, nothing has ever been ingested and waiting will not change that. Treat the Entra blade as the source of truth for MCP visibility today. If you need it queryable in Sentinel, budget for Microsoft Sentinel data lake and verify it end to end before you build detections on top of it.

Step 4: Build the Overview Tab

The built-in Network Traffic Insights workbook is a solid starting point, and I recommend exploring it first. That said, the whole reason for this series is building a view tailored to your environment, one that we’ll extend with DLP and Defender correlation tabs in Part 2. For that, we need our own workbook.

Head to Microsoft Sentinel > Threat management > Workbooks, select Add workbook, and switch to edit mode. From here, each visual is just a query item. These four are my starting lineup.

Traffic volume by action

NetworkAccessTraffic
| where TimeGenerated > ago(24h)
| summarize Events = count() by bin(TimeGenerated, 15m), Action
| render timechart

Top destinations by action

NetworkAccessTraffic
| where TimeGenerated > ago(7d)
| summarize Events = count() by DestinationFqdn, Action
| top 20 by Events

Render this one as a bar chart. It shows you at a glance which destinations dominate your traffic and whether they’re sailing through or bouncing off a policy.

Blocked traffic by user and policy

NetworkAccessTraffic
| where TimeGenerated > ago(7d)
| where Action =~ "Block"
| summarize Blocks = count() by UserPrincipalName, PolicyName, RuleName, DestinationFqdn
| top 20 by Blocks

This is the SOC-friendly view. Instead of making an analyst pivot across three blades, one grid ties the user, the policy, the specific rule, and the destination together. When someone asks, “why can’t I get to this site,” the answer is one row. Two caveats before you go looking for it. PolicyName and RuleName only populate for traffic an Internet Access filtering policy actually evaluated, so traffic riding the Microsoft profile leaves both columns empty. If nothing has tripped a block rule yet, the whole grid comes back empty, which is the right answer on a quiet tenant rather than a broken query.

Remote network health

RemoteNetworkHealthLogs
| where TimeGenerated > ago(24h)
| summarize Events = count() by RemoteNetworkId, Status

Skip this one if you’re not using remote networks. Sentinel won’t even create RemoteNetworkHealthLogs until a remote network reports in, though the query window resolves the schema anyway and just returns nothing rather than erroring. If you are using remote networks, this separates two very different stories: a blocked transaction while tunnels are healthy is a policy question, while widespread failures during a degraded tunnel is a network operations question. Knowing which fire you’re fighting is half the battle.

Here’s the finished tab after a night of real traffic. The timechart separates Allow from Block cleanly once both exist, the blocked-traffic grid ties one user to one policy, one rule, and one destination on a single row, and I tacked on a fifth tile for top cloud apps once CloudAppName turned out to be the enrichment column that actually populates.

Wrapping Up: One Drawer Down

Look at that. The junk drawer has dividers now. GSA logs are flowing into Sentinel, the official solution is installed, seven detection rules are ready to enable, and a workbook tab answers the questions that used to require three portals and a prayer.

Here’s the thing about junk drawers, though: organizing the drawer is only step one. The next step is noticing when someone tries to sneak something out of the house. If you followed my deadbolt post, your GSA file policies and Purview DLP rules are already blocking sensitive uploads. In Part 2, we’ll find out where those events actually land in Sentinel, add a DLP tab to this workbook, correlate blocked traffic with Defender device risk, and graduate from dashboards to detections.

Future you, the one who gets asked “did anyone upload customer data to that AI site,” will appreciate it.

Find Blocked Kiosk Apps at Scale with Intune Remediations

First, a small confession. I seem to have lucked out these past few years. Customer after customer has handed me enormous fleets of kiosk devices. Somewhere along the way, I actually started to enjoy the puzzle. When you work on that many kiosks, the patterns start jumping out at you. You stop troubleshooting each one from scratch and start building little tricks that get you to the fix fast. This is one of them.

In my last kiosk post, I did what most of us do when an app gets blocked. I temporarily added Event Viewer, Notepad, and Command Prompt to the kiosk, signed in at the console, opened the AppLocker log, and read the blocks by hand. It worked. It also meant I was the kiosk’s personal detective, showing up at the crime scene every single time.

That is fine when the kiosk is sitting on my bench. Kiosks do not live on my bench. They live in lobbies, break rooms, factory floors, and that one spot overseas under somebody’s desk that also happens to run the badge system. You cannot console into all of them, and you definitely do not want to.

So, this time, we flip it. Instead of you driving to the kiosk, the kiosk reports to you. By the end of this, every device will tell you which executables AppLocker blocked, that report lands in Intune, and you can read it per device without touching a single one of them.

A quick refresher on why kiosks block things

People forget what is actually doing the work here. When you build a multi-app kiosk with Assigned Access, Windows does not spin up some brand new lockdown engine. It generates AppLocker rules under the hood to allow the apps in your AllowedApps list, then enforces them. Anything not on the list gets denied.

Microsoft says this plainly in the Assigned Access policy settings docs. The kiosk allow list becomes AppLocker allow rules.

The catch is that the app you launch is rarely the thing that breaks. The launch chain is. I had a customer whose .bat file was sitting right there in the Allowed Apps list, running fine. That same .bat, however, called Find.exe. The main application worked without complaint, so on the surface everything looked healthy. One small piece of it leaned on Find.exe, which was not on the allow list, so that call got blocked and the feature quietly died. Trust me on this one, the helper binary is almost always the culprit.

The problem with doing this one device at a time

Reading Event Viewer by hand does not scale. It is slow, it requires physical or console access, and it only shows you one device. Meanwhile, you probably have a fleet of kiosks that all run the same app and might all be missing the same dependency.

What we actually want is a signal. Something each kiosk produces on its own, that we can read from one place, and that points straight at the missing executable. Intune Remediations gets us there.

Where the blocks actually live

AppLocker does not dump everything into one log. It splits events across a few channels under Applications and Services Logs > Microsoft > Windows > AppLocker. For kiosk troubleshooting, two of them matter.

LogWhat lands hereThe event ID we want
Microsoft-Windows-AppLocker/EXE and DLLBlocked Win32 executables and DLLs8004 (blocked, enforce mode)
Microsoft-Windows-AppLocker/Packaged app-ExecutionBlocked Store / UWP app launches8022 (blocked)

Event 8004 is the workhorse. 8004 means “was prevented from running” and only appears when AppLocker is in enforce mode, which is how Assigned Access runs. For blocked Store apps, the equivalent is 8022 in the Packaged app-Execution log.

One detail that will save you an hour of head-scratching if you script this yourself. AppLocker events do not store their data in the usual EventData block. They use UserData with a RuleAndFileData section, and the path lives in a FilePath field. The detection script below reads it from the right place, so you do not have to find that out the hard way like I did.

Turning it into an Intune Remediation

Here is where the kiosk starts snitching on itself. We build an Intune Remediation with a detection script only. No remediation script. We are not auto-fixing anything yet, we just want the device to report what got blocked, and Intune stores whatever the detection script writes to output.

A couple of behaviors matter for how we use this. First, a detection script that exits with code 1 tells Intune “issue detected,” which is what we want when blocks exist. Second, the output field is capped at 2,048 characters, so the script truncates before it hits that ceiling. Both are documented, and the script handles them.

The detection script

There is a catch with reading an event log on a schedule. The log is durable, but each run only looks at a slice of it. Picture a user hitting a block at 8 PM. If your scan window is short enough to keep the report current, that block is long gone by the time you sit down at 7 AM. Every run overnight saw it, reported it, then moved its window forward and forgot it. A block nobody was watching quietly vanishes.

Every run moves through four stages: it reads the AppLocker logs for a recent window (event 8004 for blocked Win32 executables, event 8022 for blocked Store apps), filters out the noise (the cmd.exe and powershell.exe troubleshooting binaries, plus the inbox Windows apps a kiosk denies by design), merges what it found into a small JSON file on the device, and writes the surviving list back out. That merge is the whole trick. The script does not rebuild the report from scratch each run. It remembers. A block from 8 PM is written to the file by the next run and is still sitting in the morning’s report. The file lives inside the Intune Management Extension log folder *\IntuneManagementExtension\Logs\KioskAppLockerBlocks.json, which is deliberate: Collect Diagnostics scoops up everything in that folder, so the full block history rides along in a support bundle without you touching the device. The output stays on one line, because Intune keeps only the last line of whatever a detection script prints. Two dials at the top steer the rest, and the next two sections explain exactly what each one changes.

# Detection script for Intune Remediations.
# Surfaces AppLocker-blocked apps for Assigned Access kiosk troubleshooting.
# Accumulates blocks into a small state file on the device, so a block that
# happened overnight is still in the report the next morning instead of aging
# out of a short scan window.
# Runs as SYSTEM. Output appears in "Pre-remediation detection output".

# --- Settings you can tune ---

# Per-run scan window: how far back each run reads the AppLocker log. Set this to at
# least your remediation interval. Wider is safe here, because results persist to disk
# between runs, so a generous window simply re-confirms blocks already recorded.
# Set ONE of these; $lookbackDays wins if it has a value.
$lookbackHours = 6       # comfortable for an hourly (or more frequent) schedule
$lookbackDays  = $null   # set instead (for example 2) if you run daily

# How long to keep a blocked app in the report after its most recent block. A fixed
# app stops generating blocks, so it ages out this many days after it was last seen.
# You can also clear a device immediately with the companion Clear scripts.
$retentionDays = 14

# Where the running list of blocks is stored on the device. This lives inside the
# Intune Management Extension log folder, so Collect Diagnostics pulls it in too.
$stateDir  = Join-Path $env:ProgramData 'Microsoft\IntuneManagementExtension\Logs'
$stateFile = Join-Path $stateDir 'KioskAppLockerBlocks.json'

$exeLog      = 'Microsoft-Windows-AppLocker/EXE and DLL'
$packagedLog = 'Microsoft-Windows-AppLocker/Packaged app-Execution'

# --- Exclusions. Add your own as you learn your environment. ---

# Win32 helpers to ignore, matched on file name (AppLocker logs %SYSTEM32% style paths).
$excludeWin32Names = @(
    'cmd.exe','powershell.exe','powershell_ise.exe','mmc.exe',
    'eventvwr.exe','notepad.exe','regedit.exe','reg.exe'
)

# Inbox / shell packaged apps a kiosk denies by design. Matched as substrings.
$excludeStoreNames = @(
    'CLIENT.OOBE','WEBEXPERIENCE','SHELLEXPERIENCEHOST','STARTMENUEXPERIENCEHOST',
    'SEARCHHOST','WINDOWS.SEARCH','CONTENTDELIVERYMANAGER','PEOPLEEXPERIENCEHOST',
    'SECHEALTHUI','CAPTURESERVICE','XGPUEJECTDIALOG','ACCOUNTSCONTROL',
    'ADDSUGGESTEDFOLDERS','PRINTQUEUEACTIONCENTER','CLIENT.CBS','CLIENT.CORE'
)

# --- Helpers ---

# AppLocker events store their payload under UserData/RuleAndFileData, not EventData/Data.
function Get-AppLockerFields {
    param([System.Diagnostics.Eventing.Reader.EventRecord]$Event)
    $result = [ordered]@{}
    try {
        $xml  = [xml]$Event.ToXml()
        $data = $xml.Event.UserData.RuleAndFileData
        if ($data) {
            foreach ($node in $data.ChildNodes) {
                if ($node.Name) { $result[$node.Name] = $node.InnerText }
            }
        }
    } catch {}
    return $result
}

# Fqbn looks like: <publisher DN>\<PackageName>\<Binary>\<Version>. Trim to the name.
function Get-StoreAppName {
    param([string]$Fqbn)
    if (-not $Fqbn) { return $null }
    $parts = $Fqbn -split '\\'
    if ($parts.Count -lt 2) { return $Fqbn }
    $pkg = $parts[1]
    $bin = if ($parts.Count -ge 3) { $parts[2] } else { $null }
    if ($bin -and $bin -notmatch '^(APPX|\*)$') { return "$pkg\$bin" }
    return $pkg
}

# --- Work out the scan window ---

if ($lookbackDays) {
    $start = (Get-Date).AddDays(-$lookbackDays)
} else {
    $start = (Get-Date).AddHours(-$lookbackHours)
}

# --- Collect this run's blocks ---

$current = New-Object System.Collections.Generic.List[object]

$blockedExe = Get-WinEvent -FilterHashtable @{
    LogName = $exeLog; Id = 8004; StartTime = $start
} -ErrorAction SilentlyContinue

foreach ($event in $blockedExe) {
    $path = (Get-AppLockerFields -Event $event)['FilePath']
    if ($path) {
        $leaf = ($path -split '\\')[-1]
        if ($leaf -and ($excludeWin32Names -notcontains $leaf.ToLower())) {
            $current.Add([pscustomobject]@{ Type = 'Win32'; Id = $path })
        }
    }
}

$blockedPkg = Get-WinEvent -FilterHashtable @{
    LogName = $packagedLog; Id = 8022; StartTime = $start
} -ErrorAction SilentlyContinue

foreach ($event in $blockedPkg) {
    $name = Get-StoreAppName -Fqbn (Get-AppLockerFields -Event $event)['Fqbn']
    if ($name) {
        $upper = $name.ToUpper()
        $skip  = $false
        foreach ($x in $excludeStoreNames) { if ($upper -like "*$x*") { $skip = $true; break } }
        if (-not $skip) {
            $current.Add([pscustomobject]@{ Type = 'Store'; Id = $name })
        }
    }
}

# --- Load the running state ---

$records = @()
if (Test-Path $stateFile) {
    try {
        $raw = [System.IO.File]::ReadAllText($stateFile)
        if ($raw.Trim()) { $records = @($raw | ConvertFrom-Json) }
    } catch { $records = @() }
}

$nowUtc = (Get-Date).ToUniversalTime()
$stamp  = $nowUtc.ToString('o')

# Index existing records by Type + Id for a quick merge.
$index = @{}
foreach ($r in $records) {
    if ($r -and $r.Type -and $r.Id) {
        $index["$($r.Type)|$($r.Id.ToUpper())"] = $r
    }
}

# Merge this run's blocks in: update LastSeen on known apps, add new ones.
foreach ($item in $current) {
    $key = "$($item.Type)|$($item.Id.ToUpper())"
    if ($index.ContainsKey($key)) {
        $index[$key].LastSeen = $stamp
    } else {
        $index[$key] = [pscustomobject]@{
            Type = $item.Type; Id = $item.Id; FirstSeen = $stamp; LastSeen = $stamp
        }
    }
}

# Age out anything not seen within the retention window.
$cutoff = $nowUtc.AddDays(-$retentionDays)
$kept = foreach ($r in $index.Values) {
    $seen = $null
    try {
        $seen = [datetime]::Parse($r.LastSeen, [cultureinfo]::InvariantCulture,
                [System.Globalization.DateTimeStyles]::RoundtripKind)
    } catch {}
    if ($seen -and $seen -ge $cutoff) { $r }
}
$kept = @($kept)

# Save the running state (UTF-8, no BOM, so it round-trips cleanly).
try {
    if (-not (Test-Path $stateDir)) { New-Item -Path $stateDir -ItemType Directory -Force | Out-Null }
    $json = if ($kept.Count -gt 0) { $kept | ConvertTo-Json -Depth 3 } else { '[]' }
    [System.IO.File]::WriteAllText($stateFile, $json, (New-Object System.Text.UTF8Encoding($false)))
} catch {}

# --- Build the single-line output from the accumulated state ---
# Intune keeps only the last line of stdout, so everything goes on one line.

$win32 = @($kept | Where-Object { $_.Type -eq 'Win32' } | ForEach-Object { $_.Id } | Sort-Object -Unique)
$store = @($kept | Where-Object { $_.Type -eq 'Store' } | ForEach-Object { $_.Id } | Sort-Object -Unique)

$segments = @()
if ($win32) { $segments += 'Win32: '    + ($win32 -join '; ') }
if ($store) { $segments += 'StoreApps: ' + ($store -join '; ') }

if ($segments.Count -eq 0) {
    Write-Output "No blocked apps recorded in the last $retentionDays day(s)"
    exit 0
}

$output = $segments -join ' | '

# Intune caps detection output at 2,048 characters. Trim if needed.
if ($output.Length -gt 2000) {
    $shortWin32 = if ($win32) { (@($win32) | Select-Object -First 8) -join '; ' } else { '' }
    $shortStore = if ($store) { (@($store) | Select-Object -First 8) -join '; ' } else { '' }
    $segments = @()
    if ($shortWin32) { $segments += "Win32: $shortWin32" }
    if ($shortStore) { $segments += "StoreApps: $shortStore" }
    $output = ($segments -join ' | ') + ' | [truncated]'
}

Write-Output $output
exit 1

Heads up: The EXE and DLL log is chatty, and Microsoft warns it can get very verbose. Now that the state file is your memory, the log only needs to hold events long enough for one scan window to catch them. On a busy kiosk, raise the log’s max size so a block is not overwritten before the next run records it.

Tuning the two dials

The two settings at the top feel similar but do opposite jobs, so it is worth keeping them straight. $lookbackHours (or $lookbackDays) is the scan window, meaning how far back each run reads the log. Its only job is to cover the gap between runs so nothing slips through. Because results persist to the state file, the lookback is not your report window: a generous window is harmless, since it just re-reads blocks already recorded, while too short a window is the one real risk, because a block that lands in a run’s dead zone never gets seen. When in doubt, go wider. Run hourly, and 6 hours is comfortable. Run daily, and $lookbackDays = 2 gives a full day of overlap.

$retentionDays is the dial that actually shapes what you see. It decides how long a blocked app stays in the report after its most recent block, and it ages out by last seen, not first seen. That distinction does all the work. An app that is still being blocked keeps getting its timestamp refreshed on every run, so it never expires, which means live problems stay visible for exactly as long as they are live. Only two kinds of entries ever age out: apps you have already fixed, since they stop generating blocks and the clock starts ticking, and true one-offs that blocked once and never came back. So retention is really your “how long should a fixed app hang around” setting. Set it to 7 for a roughly weekly self-cleanup, or bump it to 10 or 14 if you review less often and want a rare one-off to survive until your next look.

Reading the results

Once the remediation runs, head to Devices > Scripts and remediations, pick your package, and open Device status. Turn on the Pre-remediation detection output column. That is where your script’s output shows up, per device. You will see something like this for a kiosk that is missing a couple of helpers:

Win32: %PROGRAMFILES%\VendorApp\Helper.exe; %PROGRAMFILES%\VendorApp\Updater.exe

The fleet view is where this pays off. You do not need to click into every device. The Device status view has an Export button that drops the same data, output field included, into a CSV. Sort it, count the most common blocked paths, and the dependency tripping up every kiosk running that app jumps right out.

For most admins, that is the entire job. Grab the output right from Intune, either the per-device column or the exported CSV, then go fix the XML. You do not need the Graph API, extra tooling, or any script beyond the one you already deployed.

Optional: If you already live in Graph and want to fold this into existing automation or a dashboard, the same run states sit behind the beta endpoint GET beta/deviceManagement/deviceHealthScripts/{id}/deviceRunStates, in the preRemediationDetectionScriptOutput field. Consider it a nice-to-have, not part of the core workflow. The portal export covers what you need here.

Note: Remediations are not instant. How often the script actually runs depends on the schedule you set on the assignment (once, hourly, or daily). If a fresh block does not show up right away, do not panic, give it a cycle.

Fixing what you find

This is the satisfying part. You have the blocked path, so now you add it to the kiosk XML. Drop it into the AllowedApps section as a DesktopAppPath entry, just like we did in the first post.

<App DesktopAppPath="%PROGRAMFILES%\VendorApp\Helper.exe" />

Expect one quirk here. AppLocker logs paths using its own variables like %SYSTEM32%, %PROGRAMFILES%, and %OSDRIVE%, not literal C:\ paths. Good news, DesktopAppPath accepts those variable paths, so in most cases you can paste what the report gives you. When in doubt, expand the variable to confirm the real location first.

Redeploy the updated XML, let it apply, and the block clears. Rinse and repeat until the detection output comes back clean, which is your signal that the kiosk has everything it needs.

Resetting the report after a fix

The detection script now remembers what it has seen, a fixed app does not vanish from the report right away. It lingers until $retentionDays passes with no new blocks. That is deliberate. The same persistence is what stopped you from missing the overnight block in the first place. When you want a device to forget on your schedule instead, clear its state file.

The cleanest way is a second, on-demand Remediation package built from two small companion scripts. The detection half flags the state file if it exists, and the remediation half deletes it:

# Detection: Detect-ClearKioskBlockState.ps1
$stateFile = Join-Path $env:ProgramData 'Microsoft\IntuneManagementExtension\Logs\KioskAppLockerBlocks.json'
if (Test-Path $stateFile) { Write-Output "State present."; exit 1 }
Write-Output "Nothing to clear."; exit 0
# Remediation: Remediate-ClearKioskBlockState.ps1
$stateFile = Join-Path $env:ProgramData 'Microsoft\IntuneManagementExtension\Logs\KioskAppLockerBlocks.json'
try {
    if (Test-Path $stateFile) { Remove-Item $stateFile -Force -ErrorAction Stop }
    Write-Output "Cleared."; exit 0
} catch { Write-Error "Failed: $_"; exit 1 }

I usually leave this as optional. It really depends on your level of OCD. Assign that package to nothing, then fire it with Run remediation (preview) against a single device once you have addressed its apps. The next detection run starts from an empty slate, so only apps that are still genuinely blocked come back. That clear-then-confirm loop is a more reliable “did my fix work” test than waiting for an entry to age out.

Wrapping Up

Last time, I was the kiosk’s detective, showing up at the scene one device at a time with Event Viewer in hand. This time, the kiosk files its own report before I even hear there is a problem. Same AppLocker logs, same fix in the XML, just delivered to me instead of me chasing it.

Here is the rhythm to keep:

  • Remember that Assigned Access is AppLocker underneath, so blocks land in the AppLocker logs.
  • Collect 8004 for Win32 and 8022 for Store apps, and read them from UserData, not EventData.
  • Ship a detection-only Remediation to a pilot kiosk group first, then widen it.
  • Read the per-device output in Intune, export the CSV for fleet-wide patterns, and add the missing paths to AllowedApps.
  • Keep going until the report comes back clean.