There’s a special kind of frustration that comes with application control. You open the portal, find the Permit policy for the application, and it’s sitting right there. So why is it still being blocked?
That’s where I ended up while deploying an upgrade to a commercial desktop application through Microsoft Intune. The package was a Win32 app built with PowerShell App Deployment Toolkit (PSADT). On machines that had never had the application installed, it worked perfectly. On machines that already had the previous version, Intune reported:
0x87D1041C
This article covers how I tracked it down, the assumption about ThreatLocker’s policy hierarchy that I got wrong, and the time I wasted code-signing PowerShell scripts that were never going to fix it.
The error that pointed the wrong way
Intune reports 0x87D1041C as “The application was not detected after installation completed successfully.” Nearly every write-up of this error points you at the detection rule, and that’s usually the right place to start.
In my case, it was misleading. Nothing had installed at all. Intune launched the install, something stopped it immediately, and the detection rule then correctly found nothing. The error described the symptom Intune could see, not the cause.
So I went looking in the usual places. I checked the PSADT deployment logic, the install command, and registry remnants from the previous version. None of it explained why clean machines worked and upgrades didn’t.
No PSADT log means PSADT never ran
The clue that changed everything was something missing: there was no PSADT log on the failing machines.
PSADT v3 writes its logs to C:\Windows\Logs\Software by default. If the toolkit starts, it logs. On the affected machines, there was nothing for this deployment.
That meant I wasn’t debugging a failed installation. I was debugging something that stopped the installation from starting.
The Intune Management Extension logs are worth checking at the same time:
C:\ProgramData\Microsoft\IntuneManagementExtension\Logs
They show what Intune launched and how it exited, which helps confirm whether the process ever got going. Microsoft covers these in Troubleshoot Win32 app installations.
That gave me a much better question: what’s preventing PSADT from launching? Once I looked at endpoint security instead of the script, the answer was ThreatLocker.
The allow policy was there, but it wasn’t making the decision
The application already had a Permit policy at the Entire Organisation level. Its Last Match Date showed it had been matching. On paper, the application was allowed.
The Unified Audit told a different story. The deny was coming from the catch-all Default Deny policy at the bottom of the computer group the affected machines belonged to.
The Last Match Date was the trap. It proved the Permit had matched something, somewhere. It didn’t prove the Permit covered the file being blocked on these machines.
That distinction is the whole article. A Permit existing for an application is not the same as a Permit covering the specific file that’s being executed.
What I got wrong about the hierarchy
At the time, I concluded that Group-level policies must be evaluated before Organisation-level ones. I’d seen the Permit’s Order By value and assumed the tier was overriding it.
When I went back to ThreatLocker’s documentation while writing this up, I found that isn’t how the standard hierarchy works. Computer Group policies apply last, and each group ends with a Default - {GroupName} catch-all deny. Policies are processed like firewall rules, from top to bottom, and the first match wins.
So under the standard hierarchy, an Organisation-level Permit should be evaluated before the Group’s catch-all deny.
If the deny still wins, the most likely explanation is that the Permit didn’t match the file at all.
That’s a critical distinction. A ThreatLocker application isn’t a product name. It’s a definition made up of hashes, certificates and paths. A Permit only covers the files that definition describes. If an upgrade introduces a file the definition doesn’t include, that file falls through every Permit above it and lands on the default deny, no matter how correct the Permit looks.
There are two other things that can change evaluation, and they’re worth ruling out:
Flat policy structure. ThreatLocker has an optional flat policy structure where ordering works differently across tiers. Check which structure your organisation uses before assuming how evaluation works.
Built-in application precedence. By default, built-in applications take precedence over custom ones, which can produce matches you don’t expect.
I’ll be straight about the limits of what I established here. I confirmed the deny came from the group’s Default Deny policy, and I confirmed the Organisation-level Permit existed and had been matching. I did not go back and capture which specific file the audit entry named, which would have told me definitively whether the Permit simply didn’t cover it, or whether one of the two structural factors above was in play.
That omission is the reason the next section of this article exists.
The fix
I added a Permit policy for the application within the affected computer group, positioned above the group’s Default Deny. The deployment then ran as expected.
It worked, but I’d encourage you to understand why before copying it.
If the real problem is that your application definition is missing files, the cleaner fix is to update the definition so the existing Permit covers it. Otherwise you end up with duplicate Permits scattered across groups, and the next investigation is harder because there are now two places the answer could be.
A group-scoped Permit is a reasonable immediate fix for a deployment that needs to go ahead. It isn’t necessarily the right permanent answer.
Why did clean machines work?
I never conclusively established this.
The most likely explanation is that the upgrade path executes something a clean install doesn’t: an uninstall routine for the previous version, an upgrade-specific component, or a file the vendor only ships in the upgrade package. Any of those could be outside the application definition while the main executable sits comfortably inside it.
What I can say is that I initially read “only upgrades fail” as “the upgrade logic is broken.” That assumption sent me into the PSADT script for longer than it should have. The difference between the two machine types was real, but it pointed to what ran, not to a bug in the package.
If you see the same pattern, compare what actually executes on a clean install against an upgrade before rewriting anything.
Then I went down the PowerShell signing rabbit hole
Before I found the policy problem, I spent a lot of time Authenticode-signing the PSADT .ps1 files. The reasoning seemed sound: if ThreatLocker can trust software by certificate, signing my scripts should make them trusted.
In my testing, it didn’t. The signed scripts were still evaluated by hash rather than trusted by certificate. Certificate rule support varies between versions and configurations, so check your own documentation rather than taking that as universal.
There was also a nasty side effect. Signing a script adds a signature block to the file, which changes its hash. Any existing hash-based rules for those scripts stopped matching the moment I signed them. I’d taken a plausible fix and made the problem harder to see.
Code signing has legitimate uses. It just isn’t a substitute for knowing which policy is blocking you. If the problem is policy evaluation, changing the file’s identity doesn’t fix it, and it can make the situation worse.
The companion viewer had a different problem
A companion viewer application, deployed as a separate package, hit a different block.
Its InstallShield prerequisite checker extracts a DLL into a randomly named folder under C:\Windows\Temp during installation. Something like:
C:\Windows\Temp\nse1372.tmp\dotnetcore21test.dll
The folder name changes on every install, so a path-based rule can’t predict it.
What saved me was that the DLL itself is identical each time, even though its folder isn’t. A hash-based approval matches the file by its contents, so adding the hash to the viewer’s application definition solved it.
This is common with commercial installers. The application may install to a perfectly predictable path while the installer spawns temporary executables and DLLs somewhere else entirely.
When application control blocks an installer, look at the actual file that was blocked, not the application you meant to install.
I also simplified the Intune install command
While troubleshooting, I removed the ServiceUI wrapper and ran PSADT directly in silent mode.
Before:
ServiceUI.exe -Process:explorer.exe Deploy-Application.exe -DeploymentType 'install'
After:
Deploy-Application.exe -DeploymentType 'install' -DeployMode 'Silent'
These are PSADT v3 commands. If you’re on v4, the equivalent executable is Invoke-AppDeployToolkit.exe.
This was a sensible change for an unattended deployment because it avoids interactive prompts. It didn’t fix the ThreatLocker block.
That’s worth saying out loud. It’s easy to make five changes, see the next attempt succeed, and credit whichever change came last. Keep improvements and root-cause fixes separate, or you’ll carry a wrong explanation into the next incident.
Check the Unified Audit first
If I hit this again, I’d go straight to the Unified Audit before touching the deployment package, and I’d want answers to these questions:
- What executable, DLL or script was blocked?
- Which policy made the decision, and was it a Permit or a Deny?
- Is the blocked file actually included in the application definition I think covers it?
- Is my organisation using the hierarchical or flat policy structure?
- Was the blocked file created dynamically by the installer?
- Does the same component run on a working machine, and which policy allows it there?
The key is to look at the policy that made the decision, rather than searching the policy list for a Permit with the right name.
That’s exactly what I missed at first, and it’s why I can’t answer the third question about my own incident.
Design catch-all denies with the hierarchy in mind
A Default Deny gives you a strong position: anything not explicitly approved is blocked. That strength is also why definitions and hierarchy matter so much. Any file your Permits don’t describe ends up there.
When onboarding an application, I now ask more than “have we permitted it?”
- Which machines need it, and which groups are they in?
- Does the definition cover the installer, the upgrade path and any helper files, not just the main executable?
- Does the installer create files in unpredictable temporary locations?
- After a version upgrade, do the new files still match the definition?
That last question is the one that caught me.
The broader lesson about layered security
This isn’t really about one application, ThreatLocker or Intune. It’s about troubleshooting when several control layers sit between you and the thing you’re trying to run:
Intune
|
Win32 application
|
PSADT
|
PowerShell
|
ThreatLocker
|
Application installer
|
Application
I started at Intune because that’s where the error appeared, then moved to PSADT because it looked like an install problem. The clue that broke the cycle was that PSADT had never started.
That’s a pattern worth generalising. Before debugging what a component did, establish that it actually ran. If there’s no log, no process and no side effect, stop rewriting the component and find out what stopped it.
The second lesson came from writing this up. My original explanation for the fix was wrong, and I only found out by checking the vendor’s documentation afterwards. The fix worked either way, but a wrong mental model would have sent me down the wrong path next time.
Checklist: when ThreatLocker blocks an Intune deployment
- Check the Unified Audit before changing the deployment package
- Confirm whether PSADT actually started, and look for its log under
C:\Windows\Logs\Software - Check the Intune Management Extension logs to see what was launched and how it exited
- Identify the exact executable, DLL or script that was blocked
- Find the policy that made the decision, not just a Permit with the right name
- Confirm the blocked file is actually in the application definition you think covers it
- Check whether your organisation uses the hierarchical or flat policy structure
- Remember that Last Match Date shows a policy matched something, not that it matched this file
- Watch for installers that extract files to randomly named temporary folders
- Use hash-based approval when the file is consistent but its path isn’t
- Don’t assume signing a PowerShell script will make ThreatLocker trust it by certificate
- Remember that signing changes a script’s hash and can break existing hash rules
- Compare a clean install with an upgrade when only existing machines fail
- Keep deployment improvements separate from the root-cause fix
An allow policy existing in a security product doesn’t mean the application is allowed.
When something is blocked, find out which policy made the decision and what file it was looking at. Once you know that, the troubleshooting stops being guesswork.
References
- Troubleshoot Win32 app installations – Intune Management Extension logs and error codes
- PowerShell App Deployment Toolkit – PSADT documentation, including logging paths
- ThreatLocker Unified Audit – what the audit records and how to read it

From my early days on the helpdesk through roles as a service desk manager, systems administrator, and network engineer, I’ve spent more than 25 years in the IT world. As I transition into cyber security, my goal is to make tech a little less confusing by sharing what I’ve learned and helping others wherever I can.

