A practical, field-tested walkthrough: PSWindowsUpdate + PSADT v4, packaged as an Intune Win32 app, that patches a device to the newest build of its current feature version before the user ever signs in.
The Problem
The Enrollment Status Page actually has a built-in answer for this: the ESP setting “Install Windows quality updates during the out-of-box experience” (delivered via the AllowWUfBCloudProcessing / OOBE quality-update flow). Turn it on, and Autopilot installs pending quality updates during provisioning.
The catch — and the reason this post exists — is where it runs: that update pass executes in the user phase of OOBE, after the user has signed in and is sitting in front of the Enrollment Status Page. On a device that’s a few months behind, that means the user watches “Checking for updates… Installing updates…” plus a reboot, stretching an already long first-boot experience by 30–60 minutes. The whole promise of pre-provisioning is that the slow work happens in the technician phase, before handover — and the one thing that stubbornly stays user-side is often the biggest single time cost: the cumulative update.
There’s a second cost, too: until that user-phase update completes, the device is running the image’s build — often one, two, sometimes several months behind on security patches at first sign-in.
What we wanted was simple to state and surprisingly fiddly to deliver: move the quality-update work into the Autopilot pre-provisioning (technician) phase — force the device to the newest quality/security build of whatever Windows feature version it’s already on, before user enrollment — and do it reliably enough to trust in production.
The Enrollment Status Page technician phase — where all of this runs, in SYSTEM context, with no user present.
Design Goals
Before writing a line of code, the requirements were:
- Run in the technician phase (SYSTEM context, no user), as an ESP blocking app.
- Pull the newest LCU, even if a Windows Update for Business (WUfB) deferral would normally hold it back — without permanently changing production policy.
- Never perform a feature-version change (no 24H2 → 25H2 jump). Advance the build, not the version.
- Never touch drivers/firmware during provisioning (more on why that matters later — it involves a UEFI prompt that quietly killed ESP).
- Report success only if the cumulative actually installed, so a device can’t silently pass detection while still on the old build.
- Skip Windows Insider devices entirely — they get builds through their flighting channel — but skip gracefully, without failing ESP.
- Log in a way we can actually read and collect.
The core: PSWindowsUpdate in SYSTEM context
The engine is the community PSWindowsUpdate module driving the Windows Update Agent. The high-level flow:
- Detect Insider/flighting devices and skip gracefully (tag + exit 0, ESP proceeds).
- Bootstrap the module (offline-first, because PSGallery isn’t guaranteed to be reachable during OOBE).
- Temporarily lift the WUfB quality deferral so the newest LCU is offered, then restore it.
- Scan → log the plan → install quality/security updates only (no upgrades, no drivers).
- Verify the cumulative actually installed.
- Write a detection tag and return the right exit code (
3010for a soft reboot).
Let’s walk each part, with the reasoning.
Insider builds: skip gracefully, don’t fail ESP
If a device is enrolled in the Windows Insider Program, forcing production quality updates onto it is the wrong move — flighting devices get their builds through their own channel. But the skip has to be graceful: the app is an ESP blockingapp, so a plain “not for you” failure would fail provisioning.
The solution: detect Insider early, and on detection write the detection tag and exit 0 — Intune marks the app installed, ESP moves on, nothing was patched.
Detection is where the nuance lives. The obvious idea — match on build numbers — doesn’t work reliably, because Release Preview Insiders run the same build family as retail (24H2 = 26100.x, 25H2 = 26200.x). A wildcard on 26100.xwould skip your entire retail 24H2 fleet. The authoritative signal is the flighting registry, which only exists on Insider-enrolled devices:
$SelfHost = 'HKLM:\SOFTWARE\Microsoft\WindowsSelfHost\Applicability'if (Test-Path $SelfHost) { $sh = Get-ItemProperty -Path $SelfHost -ErrorAction SilentlyContinue if ($sh.BranchName -or $sh.Ring -or $sh.ContentType -or ($sh.EnablePreviewBuilds -eq 1)) { $SkipReason = "Insider/flighting device (BranchName='$($sh.BranchName)', Ring='$($sh.Ring)')" }}
For lab devices flashed straight from Insider ISOs (which may not set the flighting keys), there’s a secondary, opt-in exact-build block list — exact Build.UBR strings only, never wildcards:
# In the Config region - leave empty unless you have flashed-ISO test devices$InsiderBuildBlocklist = @('26100.1','26200.1')
Either detection → log the reason, write the tag, exit 0. ESP continues untouched.
Bootstrapping the module offline (and the “module missing on destination machine” trap)
During OOBE you cannot assume internet access to powershellgallery.com through a corporate proxy/firewall. So the module is pre-staged into the package (Save-Module PSWindowsUpdate -Path .\PSWindowsUpdate) and shipped alongside the script.
The trap: the first version simply did Import-Module <bundled folder>. Scanning worked fine — but installs failed with:
Install-WindowsUpdate : PSWindowsUpdate module missing on destination machine
This is PSWindowsUpdate checking whether the module exists in PSModulePath (it uses that for its internal task-based execution). Importing from a loose folder isn’t enough. The fix is to copy the bundled module into the system Modules directory first, then import it by name:
$Local = Join-Path $PSScriptRoot 'PSWindowsUpdate'$SystemMods = Join-Path $env:ProgramFiles 'WindowsPowerShell\Modules\PSWindowsUpdate'if (Test-Path $Local) { if (-not (Test-Path $SystemMods)) { Copy-Item -Path $Local -Destination $SystemMods -Recurse -Force } Import-Module PSWindowsUpdate -Force}
Instead of PSModulePath. Copying the module into
Program Files\WindowsPowerShell\Modulesfixes it.
Why did it work on the very first test device? Because that device fell through to the PSGallery fallback, which installs to AllUsers — i.e. into PSModulePath. Bundled-only devices exposed the bug.
Lifting the quality deferral without touching production policy
If an Intune Update Ring assigns a DeferQualityUpdatesPeriodInDays (say 7), WUfB withholds any quality update released in the last 7 days — so during provisioning the script would grab everything except the newest LCU. That defeats the purpose.
The wrong fix is to change the ring (that deferral has to survive on production devices). The right fix is a transient override: read the current value, set it to 0 for the scan, and restore it in a finally block so it’s put back even if the install errors. The Update Ring itself is never modified, and Intune reasserts the ring value on the next MDM sync anyway — a double safety net.
$PolicyPath = 'HKLM:\SOFTWARE\Microsoft\PolicyManager\current\device\Update'$OrigDefer = (Get-ItemProperty $PolicyPath -Name DeferQualityUpdatesPeriodInDays -EA SilentlyContinue).DeferQualityUpdatesPeriodInDaystry { Set-ItemProperty $PolicyPath -Name DeferQualityUpdatesPeriodInDays -Value 0 -Type DWord -EA SilentlyContinue # ... scan + install ...}finally { if ($null -ne $OrigDefer) { Set-ItemProperty $PolicyPath -Name DeferQualityUpdatesPeriodInDays -Value $OrigDefer -Type DWord -EA SilentlyContinue }}
Feature-version agnostic — advance the build, never the version
This was a key requirement: a 24H2 device should end on the latest 26100.x, a 25H2 device on the latest 26200.x, and neither should jump versions.
Here’s the reassuring part — this is how Windows servicing already works. Microsoft’s shared servicing branch means 24H2 and 25H2 share the same source, and the monthly cumulative advances each version to its own build (the same KB shows up as 26100.xxxx on 24H2 and 26200.xxxx on 25H2). The only thing that flips 24H2 → 25H2 is the enablement package (eKB), which is delivered as a “Feature update to Windows 11, version 25H2.”
So the recipe is: install quality updates, exclude the Upgrades category. For defense-in-depth I also exclude by title, so an enablement package can’t sneak the version forward even if it were ever mis-categorized. And I log the detected version so the run is auditable:
$CV = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion'$FeatureVersion = $CV.DisplayVersion # 24H2 / 25H2 / 26H2$CurrentBuild = "$($CV.CurrentBuildNumber).$($CV.UBR)" # e.g. 26100.1742Write-Output ("Current feature version: {0} (build {1})." -f $FeatureVersion, $CurrentBuild)$Pending = Get-WindowsUpdate -NotCategory 'Upgrades','Drivers' ` -NotTitle 'Feature update|Enablement Package' -EA SilentlyContinue
This same guard will hold the line when 26H2 (also an eKB for 24H2/25H2 devices) arrives.
Why drivers are excluded — the HP UEFI Physical Presence Interface story
My early versions installed drivers along with the quality updates. On HP hardware, ESP started failing at a black BIOS screen: the UEFI Physical Presence Interface (PPI).
Driver/firmware update. UEFI requires a human to approve it at the keyboard — which nobody is doing mid-ESP. Provisioning stalls here.
The PPI is a firmware anti-tampering gate: when software requests a TPM or firmware change, UEFI pauses at boot and asks a physical person to accept or reject. During ESP there’s no one at the keyboard, so the device sits on that screen and provisioning dies. Firmware updates ride in the Drivers category — and HP’s own tooling (HP Image Assistant / BIOS updates) can trigger the same prompt independently.
The fix on the update side is clean: exclude the Drivers category during provisioning. Drivers then flow through normal WUfB driver servicing after enrollment, where a reboot/prompt is far less disruptive.
Install-WindowsUpdate -NotCategory 'Upgrades','Drivers' ` -NotTitle 'Feature update|Enablement Package' ` -AcceptAll -IgnoreReboot -Verbose
Note: If you also run HP Image Assistant or push BIOS updates via another channel, address the PPI there too (e.g. pre-set the BIOS PPI behavior via HP CMSL, or sequence firmware outside ESP). Excluding drivers in this script only stops this script from being the trigger.
Live logging instead of a frozen screen
The first “smart logging” attempt piped the install through Format-Table | Out-String, which buffers — the log sat frozen at === Quality / security updates === for the entire install, looking hung when it wasn’t.
The fix: scan first and log the plan immediately, then install with the verbose stream redirected (4>&1) and relayed line-by-line through ForEach-Object, each line timestamped. No buffer, live feed:
Write-Output ("Found {0} update(s):" -f @($Pending).Count)foreach ($u in $Pending) { Write-Output (" - {0} {1} ({2})" -f $u.KB, $u.Title, $u.Size) }$InstallResults = @()Install-WindowsUpdate -NotCategory 'Upgrades','Drivers' -NotTitle 'Feature update|Enablement Package' -AcceptAll -IgnoreReboot -Verbose 4>&1 | ForEach-Object { if ($_ -is [System.Management.Automation.VerboseRecord]) { $line = $_.Message } else { $InstallResults += $_; $line = ($_ | Out-String).Trim() } if ($line) { Write-Output ("{0} {1}" -f (Get-Date -Format 'HH:mm:ss'), $line) } }
I also point the transcript at the IME Logs folder(C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\ForceUpdates.log) so it’s captured automatically by Intune Collect diagnostics and sits next to IntuneManagementExtension.log.
Don’t trust a false success — the LCU-aware result gate
An early run installed ~41 driver updates but the LCU failed, and the script still wrote its success tag and exited 0. The device reported “installed” while sitting on the old build. Unacceptable.
The result check now:
- Counts install-phase results only, de-duplicated by title (PSWindowsUpdate emits an object per phase, which had inflated my counts).
- Gate 1: if nothing installed at all →
throw→ exit1, no tag. - Gate 2 (LCU-aware): if a Cumulative Update / Security Update (KB…) was pending, it must be in the installed set — otherwise
throw→ exit1, no tag.
Because the throw happens before the tag is written, a failed cumulative can never masquerade as success.
$LcuPattern = 'Cumulative Update for Windows|Security Update \(KB'$LcuPending = @($Pending | Where-Object { $_.Title -match $LcuPattern })if ($LcuPending.Count -gt 0) { $LcuInstalled = @($InstalledOK | Where-Object { $_.Title -match $LcuPattern }) if ($LcuInstalled.Count -eq 0) { throw "Cumulative update failed to install - tag will NOT be written." } $RebootNeeded = $true # an LCU always needs a restart to commit}
Guaranteeing the reboot
Get-WURebootStatus proved unreliable immediately after install — one run installed the LCU but reported “no reboot required,” so ESP never restarted and winver stayed on the old build (the update was staged, not committed). Two fixes:
- If an LCU installed, force
$RebootNeeded = $trueunconditionally (a cumulative always needs a restart to commit). - Also check the servicing/WU pending-reboot registry markers as a fallback.
if (Get-WURebootStatus -Silent) { $RebootNeeded = $true }if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') { $RebootNeeded = $true }if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') { $RebootNeeded = $true }
The script then returns 3010, Intune’s soft-reboot code, and ESP handles the restart and resumes the technician phase.
The Full Script
The full script can be downloaded from my GitHub repo-
https://github.com/pathaksomesh06/scripts/blob/main/Force-WindowsUpdates-TechnicianPhase.ps1
Packaging
You have two clean options.
Bare script — put the script and the pre-staged PSWindowsUpdate folder together and wrap:
IntuneWinAppUtil.exe -c "<source folder>" -s "Force-WindowsUpdates-TechnicianPhase.ps1" -o "<output>"
PSADT v4 (org standard)
If your organization standardizes Win32 apps on PSAppDeployToolkit, a nice pattern is to have Invoke-AppDeployToolkit.ps1 import the bundled module and simply call the script as a child, capturing $LASTEXITCODE:
$modulePath = Join-Path $adtSession.DirFiles 'PSWindowsUpdate'$scriptPath = Join-Path $adtSession.DirFiles 'Force-WindowsUpdates-TechnicianPhase.ps1'Import-Module -Name $modulePath -Force& $scriptPath$childExitCode = [int]$LASTEXITCODE
The child’s 3010/0/1 propagates through PSADT’s reboot-exit-code handling. Make sure 3010 is in the session’s AppRebootExitCodes and reboot pass-through isn’t suppressed. In OOBE there’s no user, so run it -DeployMode Silent.
Intune app configuration
Create the Win32 app in the Intune admin center (Apps → Windows → Add → Windows app (Win32)).




Detection rule — File exists:
- Path:
C:\ProgramData\IntuneWU - File:
ForceUpdates.success.tag
Requirements — x64, minimum OS baseline, and (optionally but recommended) an OOBE requirement script so the app is only applicable during provisioning and never runs on already-provisioned production devices:
https://github.com/pathaksomesh06/scripts/blob/main/OOBE-requirement-script.ps1
Configure it as a String requirement, operator Equals, value InOOBE, run as 64-bit / System.
ESP Configuration
Add the app to the Enrollment Status Page blocking apps list so the technician phase waits for it, and give the ESP a generous timeout — a full LCU install plus reboot is not fast, and you don’t want a slow device to time out mid-commit.
Validating a Run
Grab the log from the IME folder (it’s also captured by Collect diagnostics):
Get-Content 'C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\ForceUpdates.log' -Tail 40
A healthy run reads like a story:
Current feature version: 24H2 (build 26100.1742). NO feature/version change.=== Quality / security updates ===Found 4 update(s): - KB5089549 2026-05 Security Update (KB5089549) (26100.8457) (...)07:45:31 Installed KB5007651 ...08:04:12 Installed KB5089549 ...Result check: 4 installed, 0 failed, of 4 pending.LCU check passed: 2026-05 Security Update (KB5089549) (26100.8457)Updates installed - reboot required (exit 3010).
**********************Windows PowerShell transcript startStart time: 20260612072435Username: WORKGROUP\SYSTEMRunAs User: WORKGROUP\SYSTEMConfiguration Name: Machine: WIN-BA77 (Microsoft Windows NT 10.0.26100.0)Host Application: powershell.exe -ExecutionPolicy Bypass -File Force-WindowsUpdates-TechnicianPhase.ps1Process ID: 4232PSVersion: 5.1.26100.1591PSEdition: DesktopPSCompatibleVersions: 1.0, 2.0, 3.0, 4.0, 5.0, 5.1.26100.1591BuildVersion: 10.0.26100.1591CLRVersion: 4.0.30319.42000WSManStackVersion: 3.0PSRemotingProtocolVersion: 2.3SerializationVersion: 1.1.0.1**********************Bundled PSWindowsUpdate copied to system Modules folder.=== Quality / security updates ===Found 4 update(s): - KB5007651 Update for Windows Security platform - KB5007651 (Version 10.0.29554.1001) (21MB) - KB890830 Windows Malicious Software Removal Tool x64 - v5.141 (KB890830) (83MB) - KB4052623 Update for Microsoft Defender Antivirus antimalware platform - KB4052623 (Version 4.18.26050.15) - Current Channel (Staged) (35MB) - KB5089549 2026-05 Security Update (KB5089549) (26100.8457) (90GB)07:27:40 WIN-BA77 (6/12/2026 7:27:40 AM): Connecting to Windows Update server. Please wait...07:30:28 Found [51] Updates in pre search criteria07:30:29 Found [4] Updates in post search criteria07:30:29 X ComputerName Result KB Size Title - ------------ ------ -- ---- ----- 1 WIN-BA77... Accepted KB5007651 21MB Update for Windows Security platform - KB5007651 (Version 10.0.29554.1001)07:30:29 X ComputerName Result KB Size Title - ------------ ------ -- ---- ----- 1 WIN-BA77... Accepted KB890830 83MB Windows Malicious Software Removal Tool x64 - v5.141 (KB890830)07:30:29 X ComputerName Result KB Size Title - ------------ ------ -- ---- ----- 1 WIN-BA77... Accepted KB4052623 35MB Update for Microsoft Defender Antivirus antimalware platform - KB4052623 ...07:30:29 X ComputerName Result KB Size Title - ------------ ------ -- ---- ----- 1 WIN-BA77... Accepted KB5089549 90GB 2026-05 Security Update (KB5089549) (26100.8457)07:30:29 Accepted [4] Updates ready to Download07:31:32 X ComputerName Result KB Size Title - ------------ ------ -- ---- ----- 2 WIN-BA77... Downloaded KB5007651 21MB Update for Windows Security platform - KB5007651 (Version 10.0.29554.1001)07:32:40 X ComputerName Result KB Size Title - ------------ ------ -- ---- ----- 2 WIN-BA77... Downloaded KB890830 83MB Windows Malicious Software Removal Tool x64 - v5.141 (KB890830)07:33:45 X ComputerName Result KB Size Title - ------------ ------ -- ---- ----- 2 WIN-BA77... Downloaded KB4052623 35MB Update for Microsoft Defender Antivirus antimalware platform - KB4052623 ...07:45:30 X ComputerName Result KB Size Title - ------------ ------ -- ---- ----- 2 WIN-BA77... Downloaded KB5089549 90GB 2026-05 Security Update (KB5089549) (26100.8457)07:45:30 Downloaded [4] Updates ready to Install07:45:31 X ComputerName Result KB Size Title - ------------ ------ -- ---- ----- 3 WIN-BA77... Installed KB5007651 21MB Update for Windows Security platform - KB5007651 (Version 10.0.29554.1001)07:50:15 X ComputerName Result KB Size Title - ------------ ------ -- ---- ----- 3 WIN-BA77... Installed KB890830 83MB Windows Malicious Software Removal Tool x64 - v5.141 (KB890830)07:50:51 X ComputerName Result KB Size Title - ------------ ------ -- ---- ----- 3 WIN-BA77... Installed KB4052623 35MB Update for Microsoft Defender Antivirus antimalware platform - KB4052623 ...08:04:12 X ComputerName Result KB Size Title - ------------ ------ -- ---- ----- 3 WIN-BA77... Installed KB5089549 90GB 2026-05 Security Update (KB5089549) (26100.8457)08:04:12 Installed [4] UpdatesResult check: 4 installed, 0 failed, of 4 pending.LCU check passed: 2026-05 Security Update (KB5089549) (26100.8457)Updates installed - no reboot required (exit 0).**********************Windows PowerShell transcript endEnd time: 20260612080413**********************
After the ESP soft reboot, winver shows the same feature version on the newest build — patched before the user ever signs in.
Lessons learned (the short version)

- Import from a loose folder isn’t enough — PSWindowsUpdate needs the module in
PSModulePath, or installs fail with “module missing on destination machine.” - Never change the Update Ring to beat a deferral — do a transient, self-restoring override instead.
- Exclude drivers during provisioning — firmware updates trigger the UEFI PPI prompt and hang ESP. Let drivers ride WUfB post-enrollment.
Out-Stringbuffers — redirect and stream the verbose channel for a live log.- Verify the cumulative actually installed before writing your detection tag — an exit 0 with a failed LCU is a silent regression.
- Don’t trust the WU reboot flag — force
3010when an LCU installs, and check the pending-reboot registry keys too. - Feature-version agnostic is mostly free — excluding the Upgrades category (plus a title guard for the enablement package) keeps each device on its own version while advancing the build.
- Skip Insider devices via the flighting registry, not build numbers — Release Preview shares the retail build family, so a build wildcard would skip production. And skip gracefully (tag + exit 0) so an ESP blocking app doesn’t fail provisioning on a lab device.
- Scope it to OOBE — an OOBE requirement rule keeps the app off already-provisioned devices even with a broad assignment.
Conclusion
What started as a one-liner requirement — “stop making the user watch Windows Update during OOBE; do it in the technician phase instead” — turned into a small case study in how many assumptions break inside OOBE: no user, no guaranteed internet to PSGallery, a SYSTEM context that PSWindowsUpdate treats differently, WUfB policies applying mid-provisioning, firmware prompts with nobody at the keyboard, and a detection model that will happily report success on a failure if you let it.
The end state, though, is exactly what was on the whiteboard:
- Every newly provisioned device leaves the technician phase on the newest build of its own feature version — 24H2 stays 24H2, 25H2 stays 25H2, and the enablement package can never sneak a version flip.
- The latest LCU is pulled even under a WUfB deferral, without ever modifying the production Update Ring.
- Success is provable, not assumed — the detection tag only exists if the cumulative genuinely installed, and the reboot that commits it is guaranteed.
- Production devices, Insider devices, and firmware prompts are all explicitly out of scope by design, so the solution can be assigned broadly without collateral damage.
The result the user sees is the best kind: nothing. No update nag on day one, no surprise reboot during their first Teams call — just a fully patched device from the first sign-in. And for the security team, day-zero exposure on new hardware effectively disappears.
If you take only three things from this post: pre-stage your module into PSModulePath, gate your success on the LCU actually installing, and keep drivers far away from ESP. The rest is plumbing — important plumbing, but plumbing.
Questions or improvements? Drop a comment — always keen to hear how others are handling day-zero patching in Autopilot.