A hardened server that never gets patched is just as compromised as an unpatched default install — it only takes longer to notice. The two disciplines are inseparable: a baseline shrinks your attack surface to what you can reason about, and a patch pipeline keeps that surface from rotting. This walkthrough establishes a defensible Windows Server baseline using Microsoft’s own tooling, then stands up a WSUS deployment that pushes updates through deployment rings instead of the all-or-nothing approval most shops live with.
Everything here targets Windows Server 2019/2022/2025 in a domain. Adjust ADMX template versions to match your highest OS in the fleet.
1. Establish a baseline with the Security Compliance Toolkit and LGPO
Microsoft publishes a vetted security baseline for each OS via the Security Compliance Toolkit (SCT). Do not hand-craft GPOs from a hardening guide PDF — start from the baseline and document your deviations. The SCT ships GPO backups, the PolicyAnalyzer tool, and LGPO.exe for applying local policy.
Before anything else, snapshot the current local policy so you can compare and roll back:
# Run from an elevated prompt in the extracted LGPO folder
.\LGPO.exe /b C:\baseline\backup-pre-hardening /n "Pre-hardening snapshot"
PolicyAnalyzer lets you diff the Microsoft baseline against your current effective policy and against any prior baseline. Load the baseline .PolicyRules files, add your local policy, and review the deltas before you import anything — this is where you catch settings that will break legacy apps (NTLM hardening and SMB signing are the usual suspects).
In a domain, the correct pattern is to import the baseline GPO backups into your GPMC rather than applying them locally:
Import-Module GroupPolicy
# Create the GPO, then import the baseline backup into it
New-GPO -Name "MSFT WS2022 - Member Server Baseline" |
Import-GPO -BackupId "{GUID-FROM-BASELINE-MANIFEST}" `
-Path "C:\baseline\Windows Server-2022-Security-Baseline\GPOs" `
-TargetName "MSFT WS2022 - Member Server Baseline"
The GUID comes from the
bundledcredentials/ manifest inside the baseline’sGPOsfolder. Each role (Domain Controller, Member Server, Domain Security) has its own backup. Apply the member-server baseline to member servers and the DC baseline only to the Domain Controllers OU.
Link the GPO to a dedicated OU, not the domain root, so you can pilot it on a handful of servers first. Order matters: baseline GPOs should sit below your operational GPOs in link precedence only where you intentionally override a setting, and every override belongs in a separate, clearly named GPO.
2. Attack-surface reduction: services, protocols, SMB signing, TLS
Kill SMBv1 and enforce signing
SMBv1 has no place in a 2026 environment. Remove the feature outright rather than disabling it, so it cannot be re-enabled by an errant role install:
# Remove the SMBv1 feature (requires reboot)
Disable-WindowsOptionalFeature -Online -FeatureName SMB1Protocol -NoRestart
# Enforce SMB signing on the server side
Set-SmbServerConfiguration -RequireSecuritySignature $true -EnableSecuritySignature $true -Confirm:$false
The Microsoft baseline already sets the client and server signing policies via GPO (Microsoft network server: Digitally sign communications (always)), but verify it took — misconfigured signing between a hardened server and a legacy NAS is a classic post-baseline outage.
Disable legacy and unused protocols
Audit listening services and shut down what you do not need. NetBIOS over TCP/IP, LLMNR, and mDNS are common lateral-movement enablers:
# Disable LLMNR via registry (also available as a GPO under DNS Client)
New-Item -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\DNSClient" -Force | Out-Null
Set-ItemProperty -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\DNSClient" `
-Name "EnableMulticast" -Value 0 -Type DWord
TLS / Schannel configuration
Disable obsolete protocols at the Schannel layer so .NET, IIS, and SQL inherit a sane default. Disable SSL 2.0/3.0 and TLS 1.0/1.1; leave TLS 1.2 and 1.3 enabled. The reliable approach is registry-based:
$protocols = "SSL 2.0","SSL 3.0","TLS 1.0","TLS 1.1"
$base = "HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols"
foreach ($p in $protocols) {
foreach ($role in "Server","Client") {
$path = "$base\$p\$role"
New-Item -Path $path -Force | Out-Null
Set-ItemProperty -Path $path -Name "Enabled" -Value 0 -Type DWord
Set-ItemProperty -Path $path -Name "DisabledByDefault" -Value 1 -Type DWord
}
}
Reboot is required for Schannel changes to take effect. Before disabling TLS 1.0/1.1 broadly, confirm nothing in the fleet still depends on it — old SQL client drivers and some appliance management interfaces are frequent offenders. Stage this through the same pilot OU as your baseline.
3. Local accounts, audit policy, and PowerShell logging
Local account hardening
The single highest-value control for lateral movement is randomizing local administrator passwords. Use Windows LAPS (built into modern Windows and managed via the LAPS PowerShell module), not the legacy MSI:
# Confirm Windows LAPS is present and view current policy state
Get-Command -Module LAPS
Get-LapsADPassword -Identity "SRV-APP-01" -AsPlainText
Configure LAPS policy (backup directory, password complexity, rotation age) via the dedicated LAPS GPO node. The baseline also disables the built-in Guest account and renames the built-in Administrator — keep both.
Advanced audit policy
Stop relying on legacy audit categories; the baseline configures the advanced audit subcategories, which are what actually feed your SIEM. Verify the effective configuration:
# Dump effective advanced audit policy
auditpol /get /category:*
At minimum you want success+failure on Logon/Logoff, Account Logon (Kerberos/credential validation), Account Management, and Detailed Tracking (process creation). Process creation events (4688) are far more useful with command-line capture enabled, which the baseline turns on.
PowerShell logging
Attackers live in PowerShell, so log it. Enable script block logging and module logging via GPO (Administrative Templates > Windows Components > Windows PowerShell). The registry equivalents:
$psBase = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell"
New-Item -Path "$psBase\ScriptBlockLogging" -Force | Out-Null
Set-ItemProperty -Path "$psBase\ScriptBlockLogging" `
-Name "EnableScriptBlockLogging" -Value 1 -Type DWord
Pair this with constrained transcription to a write-protected share, and ship Microsoft-Windows-PowerShell/Operational (event ID 4104) to your collector.
4. Stand up WSUS: database, content store, upstream sync
Install the role with the WID database for small/medium fleets, or point at a SQL instance for larger ones. The content directory must live on a volume with room to grow — plan for tens of GB minimum, far more if you store multiple product/language combinations.
# Install WSUS with WID and the management console
Install-WindowsFeature -Name UpdateServices, UpdateServices-WidDB, UpdateServices-Services, UpdateServices-UI
# Post-install configuration: point at the content store
& "$env:ProgramFiles\Update Services\Tools\wsusutil.exe" postinstall CONTENT_DIR=D:\WSUS
Run initial configuration through PowerShell so it is repeatable. Sync from Microsoft Update upstream, select products and classifications deliberately, and set the languages you actually deploy:
$wsus = Get-WsusServer
$config = $wsus.GetConfiguration()
# Sync directly from Microsoft Update
$config.SyncFromMicrosoftUpdate = $true
$config.AllUpdateLanguagesEnabled = $false
$config.SetEnabledUpdateLanguages("en")
$config.Save()
# Kick off the initial category sync (categories first, then content)
$sub = $wsus.GetSubscription()
$sub.StartSynchronization()
After the category sync completes, restrict products and classifications. Pulling “all products” is the classic mistake that bloats the content store and slows every sync:
# Enable only the products you run
Get-WsusProduct | Where-Object { $_.Product.Title -match "Windows Server 2022|Windows 11" } |
Set-WsusProduct
# Classifications: security, critical, definition, and update rollups
Get-WsusClassification |
Where-Object { $_.Classification.Title -in @("Security Updates","Critical Updates","Definition Updates","Update Rollups") } |
Set-WsusClassification
Do not enable “Drivers” unless you have a specific need — driver content is enormous and approving the wrong one can brick hardware. Set a recurring synchronization (e.g., daily) once products are dialed in.
5. Computer groups and deployment rings
The whole point of WSUS is controlled rollout. Create groups that map to rings, not to org charts. A typical layout:
| Ring | Group | Purpose | Approval timing |
|---|---|---|---|
| 0 | Pilot | A few non-critical servers + IT machines | Day 0 |
| 1 | Early | Broader low-risk workloads | Day +3 |
| 2 | Broad | Majority of the fleet | Day +7 |
| 3 | Critical | DCs, SQL, line-of-business | Day +10, manual |
"Ring0-Pilot","Ring1-Early","Ring2-Broad","Ring3-Critical" |
ForEach-Object { $wsus.CreateComputerTargetGroup($_) }
Use client-side targeting (computers self-assign to a group via GPO) so ring membership is declarative and travels with the OU structure, rather than dragging machines around in the console.
Auto-approval rules can drive the pilot ring so security updates flow without manual touch, while later rings stay gated:
$rule = $wsus.CreateInstallApprovalRule("Auto-approve Security to Pilot")
$class = $wsus.GetUpdateClassifications() |
Where-Object { $_.Title -in @("Security Updates","Critical Updates") }
$cc = New-Object Microsoft.UpdateServices.Administration.UpdateClassificationCollection
$cc.AddRange($class)
$rule.SetUpdateClassifications($cc)
$groups = New-Object Microsoft.UpdateServices.Administration.ComputerTargetGroupCollection
$groups.Add(($wsus.GetComputerTargetGroups() | Where-Object { $_.Name -eq "Ring0-Pilot" }))
$rule.SetComputerTargetGroups($groups)
$rule.Enabled = $true
$rule.Save()
6. Driving approvals, declines, and cleanup with PowerShell
Promote updates from ring to ring once they bake. This approves everything currently needed-and-not-declined for the next group:
$target = $wsus.GetComputerTargetGroups() | Where-Object { $_.Name -eq "Ring1-Early" }
$wsus.GetUpdates() |
Where-Object { -not $_.IsDeclined -and $_.IsLatestRevision } |
ForEach-Object { $_.Approve("Install", $target) | Out-Null }
Decline superseded and expired updates aggressively — they are the main reason WSUS clients churn for hours scanning. Superseded content is the silent killer of WSUS performance:
$wsus.GetUpdates() |
Where-Object { $_.IsSuperseded -or $_.PublicationState -eq "Expired" } |
ForEach-Object { $_.Decline() }
Run the server cleanup wizard programmatically on a schedule. This is non-negotiable maintenance, not an occasional chore:
$scope = New-Object Microsoft.UpdateServices.Administration.CleanupScope
$scope.DeclineSupersededUpdates = $true
$scope.DeclineExpiredUpdates = $true
$scope.CleanupObsoleteUpdates = $true
$scope.CleanupUnneededContentFiles = $true
$scope.CleanupObsoleteComputers = $true
$scope.CompressUpdates = $true
$mgr = $wsus.GetCleanupManager()
$mgr.PerformCleanup($scope)
On large WSUS databases, also re-index the SUSDB periodically. WID is reachable via the named-pipe connection string
\\.\pipe\MICROSOFT##WID\tsql\querywithsqlcmd. Run the well-known WSUS re-index script against it as part of monthly maintenance — without it, console operations time out.
7. Client targeting via Group Policy and stuck wuauclt clients
Point clients at WSUS and assign their ring through GPO under Administrative Templates > Windows Components > Windows Update. The key settings:
- Specify intranet Microsoft update service location -> set both the update and statistics URLs to
http://wsus.corp.example:8530. - Enable client-side targeting -> set the target group name to match the ring (e.g.,
Ring2-Broad). Apply this per-OU so each ring gets the right value. - Configure Automatic Updates -> download and notify, or scheduled install per your maintenance window.
WSUS uses port 8530 (HTTP) and 8531 (HTTPS) by default — not 80/443. Getting the port wrong is the most common reason clients report “can’t connect.” If you front WSUS with HTTPS, configure the binding and use the 8531 URL.
When a client goes silent, the modern troubleshooting flow does not use the deprecated wuauclt /detectnow. On Windows 10/11 and Server 2016+, use UsoClient and force a re-registration when a client’s ID is duplicated (the classic symptom after cloning a VM without sysprep):
# Force a scan against WSUS
UsoClient StartScan
# Fix duplicate SusClientId after a bad clone: stop, clear, re-register
Stop-Service wuauserv, bits -Force
Remove-Item "HKLM:\SOFTWARE\Microsoft\Windows\WindowsUpdate\SusClientId" -ErrorAction SilentlyContinue
Remove-Item "$env:windir\SoftwareDistribution" -Recurse -Force -ErrorAction SilentlyContinue
Start-Service wuauserv, bits
wuauclt /resetauthorization
UsoClient StartScan
Duplicate
SusClientIdvalues are the number-one cause of “phantom” clients that share a record in the console and never report correctly. Always sysprep your golden image, or clear the ID in your VM provisioning pipeline.
The authoritative log on a stuck client is no longer WindowsUpdate.log as a flat file — it is ETW. Reconstruct it on demand:
# Regenerate the merged WindowsUpdate.log on the desktop
Get-WindowsUpdateLog
Enterprise scenario
A retail platform team I worked with applied the Microsoft member-server baseline to a freshly built OU and immediately lost half their monitoring. Their Zabbix and legacy backup agents talked to the servers over SMB to pull logs from admin shares, and the baseline’s Microsoft network server: Digitally sign communications (always) flipped signing to required on both ends. The agents’ embedded SMB stack didn’t negotiate signing, so every connection silently dropped — no event, just gaps in graphs. The instinct was to relax SMB signing globally. Wrong move: that re-opens the door the baseline closed.
The fix was to keep signing required and bring the laggards up to spec instead. We confirmed which side was failing with the SMB client failure-audit channel rather than guessing:
# Server side: are clients failing the signing negotiation?
Get-WinEvent -LogName "Microsoft-Windows-SMBServer/Security" -MaxEvents 50 |
Where-Object { $_.Id -eq 1006 } |
Select-Object TimeCreated, @{N='Client';E={$_.Properties[0].Value}}
Event 1006 named the exact backup proxy. We upgraded that agent to a build with SMB2 signing support, isolated the two unfixable appliances into their own OU with a documented, separately-named override GPO, and left the fleet-wide policy hardened. Patch latency on those appliances became a tracked exception, not a baseline hole. The lesson: when a baseline breaks something, audit which endpoint is non-compliant and fix that — never weaken a fleet-wide control to accommodate the weakest device.
Verify
Confirm the baseline, the server, and the clients independently — never assume a GPO applied just because you linked it.
# 1. Baseline applied? Confirm SMBv1 gone and signing required
Get-WindowsOptionalFeature -Online -FeatureName SMB1Protocol | Select-Object State
Get-SmbServerConfiguration | Select-Object RequireSecuritySignature
# 2. Schannel: TLS 1.0 server side disabled
Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.0\Server" `
-Name Enabled -ErrorAction SilentlyContinue
# 3. PowerShell + audit logging live
auditpol /get /subcategory:"Process Creation"
Get-ItemProperty "HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging" -Name EnableScriptBlockLogging
# 4. Client points at WSUS and reports a recent scan
Get-ItemProperty "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate" -Name WUServer, TargetGroup
On the server, verify clients are actually checking in and that ring approvals are landing:
$wsus = Get-WsusServer
# Computers that have not reported in 7 days are a red flag
$wsus.GetComputerTargets() |
Where-Object { $_.LastReportedStatusTime -lt (Get-Date).AddDays(-7) } |
Select-Object FullDomainName, LastReportedStatusTime, ComputerRole
Reporting compliance and measuring patch latency
A patch program you cannot measure is a patch program you cannot defend in an audit. Build a per-computer compliance summary straight from the WSUS object model:
$wsus = Get-WsusServer
$scope = New-Object Microsoft.UpdateServices.Administration.ComputerTargetScope
$wsus.GetComputerTargets($scope) | ForEach-Object {
$c = $_
$s = $c.GetUpdateInstallationSummary()
[pscustomobject]@{
Computer = $c.FullDomainName
Group = ($c.GetComputerTargetGroups() | Select-Object -First 1).Name
NeededCount = $s.NotInstalledCount + $s.DownloadedCount
FailedCount = $s.FailedCount
LastReported = $c.LastReportedStatusTime
}
} | Sort-Object NeededCount -Descending | Format-Table -AutoSize
The metric that matters is patch latency — the gap between an update’s release and its install across each ring. Track the 90th percentile per ring, not the average; the average hides the long tail of machines that never reboot. A healthy fleet shows Pilot near-zero, Broad inside your SLA window, and Critical trailing intentionally. When the Critical ring’s tail blows past your window, that is your signal to chase reboot enforcement, not to approve faster.
Hardening + patching checklist
Pitfalls and next steps
The failure modes here are predictable. Applying the baseline to the domain root instead of a pilot OU turns one bad setting into a fleet-wide outage. Skipping WSUS cleanup lets the SUSDB and content store balloon until scans take hours and the console times out. Cloning VMs without clearing the SusClientId produces phantom clients that look compliant but never patch. And disabling TLS 1.0/1.1 before inventorying dependencies will silently break the one legacy appliance nobody documented.
For next steps, layer Microsoft Defender for Endpoint’s vulnerability management on top of WSUS reporting so you correlate “patch approved” with “exposure closed,” and evaluate moving internet-facing or roaming machines to Windows Update for Business with update rings defined in Intune — WSUS remains the right tool for tightly controlled on-prem fleets, but it should not be your only patch authority forever.