How to Use PowerShell for Security Hardening Automation on Windows Server 2025
Manual security hardening is error-prone, time-consuming, and impossible to scale across a fleet of servers. PowerShell changes that equation entirely. Windows Server 2025 ships with PowerShell 5.1 and supports PowerShell 7.x side-by-side, giving administrators a mature scripting platform capable of querying, configuring, and auditing every security control on the operating system. In this tutorial you will build a complete hardening script that disables legacy authentication protocols, enforces SMB signing, locks down NTLM, enables audit policies, neutralises the built-in guest account, verifies Windows Defender is active, and produces a plain-text compliance report — all in a single automated run.
Prerequisites
- Windows Server 2025 (Standard or Datacenter edition)
- An account with local Administrator or Domain Administrator privileges
- PowerShell 5.1 or later (type
$PSVersionTable.PSVersionto confirm) - The
SmbShareandDefendermodules available — both are inbox on Server 2025 - A test or staging server — validate all changes before running in production
- Script execution policy set to at least
RemoteSigned:Set-ExecutionPolicy RemoteSigned -Force
Step 1: Scaffold the Hardening Script
Create a new file called Invoke-ServerHardening.ps1 and open it in your editor. The script will collect a transcript of every change it makes and write a report at the end. Start with the following framework:
#Requires -RunAsAdministrator
[CmdletBinding(SupportsShouldProcess)]
param(
[string]$ReportPath = "C:HardeningReports$(Get-Date -Format 'yyyyMMdd_HHmmss')_HardeningReport.txt"
)
$ErrorActionPreference = 'Stop'
$results = [System.Collections.Generic.List[PSCustomObject]]::new()
function Write-Result {
param([string]$Control, [string]$Status, [string]$Detail)
$entry = [PSCustomObject]@{
Timestamp = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
Control = $Control
Status = $Status
Detail = $Detail
}
$results.Add($entry)
$colour = if ($Status -eq 'PASS') { 'Green' } elseif ($Status -eq 'FAIL') { 'Red' } else { 'Yellow' }
Write-Host "[$Status] $Control — $Detail" -ForegroundColor $colour
}
# Ensure report directory exists
$null = New-Item -ItemType Directory -Path (Split-Path $ReportPath) -Force
Step 2: Disable SMBv1
SMBv1 is the protocol exploited by EternalBlue and is the root cause of WannaCry and NotPetya outbreaks. Windows Server 2025 disables it by default, but the setting can be re-enabled inadvertently by legacy application installers. Explicitly enforce it:
# --- Control: Disable SMBv1 ---
try {
Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force -Confirm:$false
$current = (Get-SmbServerConfiguration).EnableSMB1Protocol
if (-not $current) {
Write-Result -Control 'SMBv1 Disabled' -Status 'PASS' `
-Detail 'EnableSMB1Protocol is False'
} else {
Write-Result -Control 'SMBv1 Disabled' -Status 'FAIL' `
-Detail 'EnableSMB1Protocol is still True — reboot may be required'
}
} catch {
Write-Result -Control 'SMBv1 Disabled' -Status 'ERROR' -Detail $_.Exception.Message
}
Step 3: Enforce SMB Signing
Without mandatory SMB signing, a man-in-the-middle attacker can relay authentication tokens or tamper with file transfers. Requiring security signatures on both server and client sides mitigates relay attacks:
# --- Control: Require SMB Signing ---
try {
Set-SmbServerConfiguration -RequireSecuritySignature $true -Force -Confirm:$false
Set-SmbClientConfiguration -RequireSecuritySignature $true -Force -Confirm:$false
$srvSig = (Get-SmbServerConfiguration).RequireSecuritySignature
$cltSig = (Get-SmbClientConfiguration).RequireSecuritySignature
if ($srvSig -and $cltSig) {
Write-Result -Control 'SMB Signing Required' -Status 'PASS' `
-Detail 'Both server and client require signing'
} else {
Write-Result -Control 'SMB Signing Required' -Status 'FAIL' `
-Detail "Server=$srvSig Client=$cltSig"
}
} catch {
Write-Result -Control 'SMB Signing Required' -Status 'ERROR' -Detail $_.Exception.Message
}
Step 4: Disable LM Hash Storage
LAN Manager hashes are computationally trivial to crack. The registry key NoLMHash under the LSA subkey tells Windows never to store the LM representation of passwords — a change that takes effect at the next password change:
# --- Control: Disable LM Hash Storage ---
try {
$lsaPath = 'HKLM:SYSTEMCurrentControlSetControlLsa'
Set-ItemProperty -Path $lsaPath -Name 'NoLMHash' -Value 1 -Type DWord
$val = (Get-ItemProperty -Path $lsaPath -Name 'NoLMHash').NoLMHash
if ($val -eq 1) {
Write-Result -Control 'LM Hash Storage Disabled' -Status 'PASS' `
-Detail 'NoLMHash = 1'
} else {
Write-Result -Control 'LM Hash Storage Disabled' -Status 'FAIL' `
-Detail "NoLMHash = $val"
}
} catch {
Write-Result -Control 'LM Hash Storage Disabled' -Status 'ERROR' -Detail $_.Exception.Message
}
Step 5: Set NTLMv2 Authentication Level
The LAN Manager Authentication Level registry value controls which NTLM challenge/response variants Windows will use. Level 5 means the system will only send NTLMv2 responses and refuse LM and NTLMv1, which prevents downgrade attacks:
# --- Control: NTLMv2 Only (LMCompatibilityLevel = 5) ---
try {
$lsaPath = 'HKLM:SYSTEMCurrentControlSetControlLsa'
Set-ItemProperty -Path $lsaPath -Name 'LmCompatibilityLevel' -Value 5 -Type DWord
$val = (Get-ItemProperty -Path $lsaPath -Name 'LmCompatibilityLevel').LmCompatibilityLevel
if ($val -eq 5) {
Write-Result -Control 'NTLMv2 Enforcement' -Status 'PASS' `
-Detail 'LmCompatibilityLevel = 5 (NTLMv2 responses only)'
} else {
Write-Result -Control 'NTLMv2 Enforcement' -Status 'FAIL' `
-Detail "LmCompatibilityLevel = $val (expected 5)"
}
} catch {
Write-Result -Control 'NTLMv2 Enforcement' -Status 'ERROR' -Detail $_.Exception.Message
}
Step 6: Configure Audit Policies
The built-in auditpol.exe command configures the advanced audit policy subcategories that feed the Security event log. Enable the categories most valuable for threat detection:
# --- Control: Audit Policies ---
$auditSettings = @(
@{ Category = 'Account Logon'; SubCategory = 'Credential Validation'; Setting = 'Success,Failure' }
@{ Category = 'Account Management'; SubCategory = 'User Account Management'; Setting = 'Success,Failure' }
@{ Category = 'Logon/Logoff'; SubCategory = 'Logon'; Setting = 'Success,Failure' }
@{ Category = 'Logon/Logoff'; SubCategory = 'Special Logon'; Setting = 'Success' }
@{ Category = 'Detailed Tracking'; SubCategory = 'Process Creation'; Setting = 'Success' }
@{ Category = 'Object Access'; SubCategory = 'Other Object Access Events'; Setting = 'Success,Failure' }
@{ Category = 'Policy Change'; SubCategory = 'Audit Policy Change'; Setting = 'Success,Failure' }
@{ Category = 'Privilege Use'; SubCategory = 'Sensitive Privilege Use'; Setting = 'Success,Failure' }
)
foreach ($policy in $auditSettings) {
try {
$args = "/set /subcategory:`"$($policy.SubCategory)`" /$($policy.Setting.Replace(',', ' /').ToLower())"
$result = Start-Process -FilePath 'auditpol.exe' -ArgumentList $args `
-NoNewWindow -Wait -PassThru
if ($result.ExitCode -eq 0) {
Write-Result -Control "Audit: $($policy.SubCategory)" -Status 'PASS' `
-Detail "$($policy.Setting) enabled"
} else {
Write-Result -Control "Audit: $($policy.SubCategory)" -Status 'FAIL' `
-Detail "auditpol exit code $($result.ExitCode)"
}
} catch {
Write-Result -Control "Audit: $($policy.SubCategory)" -Status 'ERROR' `
-Detail $_.Exception.Message
}
}
Step 7: Disable the Guest Account
The built-in Guest account provides anonymous access with no password by default. Disabling it closes an obvious entry point:
# --- Control: Disable Guest Account ---
try {
$guest = Get-LocalUser -Name 'Guest' -ErrorAction SilentlyContinue
if ($guest) {
if ($guest.Enabled) {
Disable-LocalUser -Name 'Guest'
}
$refreshed = Get-LocalUser -Name 'Guest'
$status = if (-not $refreshed.Enabled) { 'PASS' } else { 'FAIL' }
Write-Result -Control 'Guest Account Disabled' -Status $status `
-Detail "Guest.Enabled = $($refreshed.Enabled)"
} else {
Write-Result -Control 'Guest Account Disabled' -Status 'PASS' `
-Detail 'Guest account does not exist on this system'
}
} catch {
Write-Result -Control 'Guest Account Disabled' -Status 'ERROR' -Detail $_.Exception.Message
}
Step 8: Verify Windows Defender is Active
Windows Defender Antivirus ships with Server 2025 and should remain enabled unless replaced by a third-party solution. This check confirms real-time protection is running:
# --- Control: Windows Defender Real-Time Protection ---
try {
$mpStatus = Get-MpComputerStatus
if ($mpStatus.RealTimeProtectionEnabled) {
Write-Result -Control 'Defender Real-Time Protection' -Status 'PASS' `
-Detail "AntivirusEnabled=$($mpStatus.AntivirusEnabled) SignatureAge=$($mpStatus.AntivirusSignatureAge)d"
} else {
Write-Result -Control 'Defender Real-Time Protection' -Status 'FAIL' `
-Detail 'RealTimeProtectionEnabled is False'
}
} catch {
Write-Result -Control 'Defender Real-Time Protection' -Status 'ERROR' -Detail $_.Exception.Message
}
Step 9: Generate the Compliance Report
Finish the script by writing all collected results to the report file and printing a summary to the console:
# --- Generate Report ---
$pass = ($results | Where-Object Status -eq 'PASS').Count
$fail = ($results | Where-Object Status -eq 'FAIL').Count
$error = ($results | Where-Object Status -eq 'ERROR').Count
$header = @"
==========================================================
Windows Server 2025 Security Hardening Report
Generated : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
Computer : $env:COMPUTERNAME
Domain : $env:USERDOMAIN
==========================================================
PASS: $pass FAIL: $fail ERROR: $error
----------------------------------------------------------
"@
$body = $results | Format-Table -AutoSize | Out-String
$report = $header + $body
$report | Out-File -FilePath $ReportPath -Encoding UTF8
Write-Host "`nReport saved to: $ReportPath" -ForegroundColor Cyan
Write-Host "PASS: $pass FAIL: $fail ERROR: $error" -ForegroundColor White
if ($fail -gt 0 -or $error -gt 0) {
exit 1 # Non-zero exit for CI/CD pipeline detection
}
Run the completed script from an elevated PowerShell console with .Invoke-ServerHardening.ps1. The script will display colour-coded results in real time and write the full report to C:HardeningReports. Integrate it into your provisioning pipeline — whether that is a custom DSC configuration, an Azure Policy guest configuration, or a simple scheduled task — so every server that joins the environment is evaluated against the same baseline from day one. Pair it with recurring scheduled runs and centralised log collection to maintain continuous compliance evidence for auditors.