
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.
| Log | What lands here | The event ID we want |
| Microsoft-Windows-AppLocker/EXE and DLL | Blocked Win32 executables and DLLs | 8004 (blocked, enforce mode) |
| Microsoft-Windows-AppLocker/Packaged app-Execution | Blocked Store / UWP app launches | 8022 (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.

