Forcing the Windows Updates During Autopilot Pre-Provisioning (ESP Technician Phase)

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:

  1. Detect Insider/flighting devices and skip gracefully (tag + exit 0, ESP proceeds).
  2. Bootstrap the module (offline-first, because PSGallery isn’t guaranteed to be reachable during OOBE).
  3. Temporarily lift the WUfB quality deferral so the newest LCU is offered, then restore it.
  4. Scan → log the plan → install quality/security updates only (no upgrades, no drivers).
  5. Verify the cumulative actually installed.
  6. Write a detection tag and return the right exit code (3010 for 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\Modules fixes 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).DeferQualityUpdatesPeriodInDays
try {
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.1742
Write-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 → exit 1no tag.
  • Gate 2 (LCU-aware): if a Cumulative Update / Security Update (KB…) was pending, it must be in the installed set — otherwise throw → exit 1no 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 = $true unconditionally (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).
ForceUpdates.log
**********************
Windows PowerShell transcript start
Start time: 20260612072435
Username: WORKGROUP\SYSTEM
RunAs User: WORKGROUP\SYSTEM
Configuration Name:
Machine: WIN-BA77 (Microsoft Windows NT 10.0.26100.0)
Host Application: powershell.exe -ExecutionPolicy Bypass -File Force-WindowsUpdates-TechnicianPhase.ps1
Process ID: 4232
PSVersion: 5.1.26100.1591
PSEdition: Desktop
PSCompatibleVersions: 1.0, 2.0, 3.0, 4.0, 5.0, 5.1.26100.1591
BuildVersion: 10.0.26100.1591
CLRVersion: 4.0.30319.42000
WSManStackVersion: 3.0
PSRemotingProtocolVersion: 2.3
SerializationVersion: 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 criteria
07:30:29 Found [4] Updates in post search criteria
07: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 Download
07: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 Install
07: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] Updates
Result 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 end
End 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-String buffers — 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 3010 when 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.

Categories: Intune, Windows

Leave a Reply

Cookies Notice

Intune - In Real Life, uses cookies. If you continue to use this site it is assumed that you are happy with this.

Discover more from Intune - In Real Life

Subscribe now to keep reading and get access to the full archive.

Continue reading