Why Automate Security Hardening with PowerShell

Manual security hardening of Windows Server 2022 is error-prone, time-consuming, and difficult to audit or reproduce. A single missed registry key or firewall rule on one server can leave an entire environment exposed. PowerShell-based hardening automation solves these problems by encoding your security configuration as executable, version-controlled code that can be applied consistently across dozens or hundreds of servers, verified against a known-good baseline, and re-run idempotently without side effects.

This article covers practical PowerShell patterns for automating the most important security hardening tasks on Windows Server 2022: disabling unnecessary services, configuring firewall rules, applying registry-based security settings for SCHANNEL, SMB, and NTLM, configuring audit policies, setting account lockout policies, and generating a post-hardening compliance report. All scripts are designed to be idempotent — safe to run multiple times without causing errors or unintended state changes.

Run all hardening scripts in an elevated PowerShell session (Run as Administrator). For production deployments, test every script against a non-production server first and validate that all required services and applications continue to function correctly after hardening.

Disabling Unnecessary Services

Windows Server 2022 ships with numerous services enabled by default that are not required in most server roles. Each running service represents a potential attack surface. The principle of least functionality dictates that only services necessary for the server’s specific role should be running.

The following services are commonly disabled on servers that do not require them. Verify against your specific server role before disabling each one — for example, do not disable the Print Spooler service on a dedicated print server.

# Define services to disable - adjust list per server role
$ServicesToDisable = @(
    "Spooler",          # Print Spooler - disable on non-print servers
    "RemoteRegistry",   # Remote Registry - security risk, rarely needed
    "SSDPSRV",          # SSDP Discovery - UPnP discovery, not needed on servers
    "upnphost",         # UPnP Device Host
    "WMPNetworkSvc",    # Windows Media Player Network Sharing
    "XblAuthManager",   # Xbox Live Auth Manager
    "XblGameSave",      # Xbox Live Game Save
    "XboxNetApiSvc",    # Xbox Live Networking Service
    "wercplsupport",    # Problem Reports Control Panel
    "WerSvc",           # Windows Error Reporting Service
    "MapsBroker",       # Downloaded Maps Manager
    "lfsvc",            # Geolocation Service
    "wisvc",            # Windows Insider Service
    "WSearch"           # Windows Search - disable on servers without desktop search need
)

foreach ($ServiceName in $ServicesToDisable) {
    $svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
    if ($svc) {
        if ($svc.Status -eq "Running") {
            Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue
            Write-Host "Stopped service: $ServiceName"
        }
        Set-Service -Name $ServiceName -StartupType Disabled -ErrorAction SilentlyContinue
        Write-Host "Disabled service: $ServiceName"
    } else {
        Write-Host "Service not found (OK): $ServiceName"
    }
}

After running this script, verify the critical services your applications depend on are still running. Use Get-Service to check the status of any service. The idempotent design means re-running this script on an already-hardened server will simply report the current state without causing errors.

Configuring Firewall Rules in Bulk

Windows Firewall with Advanced Security should be configured to block all inbound traffic by default, with explicit allow rules only for required services. The New-NetFirewallRule cmdlet enables scripted creation of firewall rules across all profiles (Domain, Private, Public).

# Set default inbound policy to Block for all profiles
Set-NetFirewallProfile -Profile Domain,Private,Public -DefaultInboundAction Block
Set-NetFirewallProfile -Profile Domain,Private,Public -DefaultOutboundAction Allow
Set-NetFirewallProfile -Profile Domain,Private,Public -Enabled True
Set-NetFirewallProfile -Profile Domain,Private,Public -LogAllowed True -LogBlocked True `
    -LogMaxSizeKilobytes 32767

# Allow RDP only from management subnet (replace with your management CIDR)
$ManagementSubnet = "192.168.10.0/24"

# Remove any existing broad RDP rules first (idempotent)
Remove-NetFirewallRule -DisplayName "Allow RDP from Management" -ErrorAction SilentlyContinue

New-NetFirewallRule `
    -DisplayName "Allow RDP from Management" `
    -Direction Inbound `
    -Protocol TCP `
    -LocalPort 3389 `
    -RemoteAddress $ManagementSubnet `
    -Action Allow `
    -Profile Any `
    -Enabled True

# Allow WinRM from management subnet (for PSRemoting)
Remove-NetFirewallRule -DisplayName "Allow WinRM from Management" -ErrorAction SilentlyContinue
New-NetFirewallRule `
    -DisplayName "Allow WinRM from Management" `
    -Direction Inbound `
    -Protocol TCP `
    -LocalPort 5985,5986 `
    -RemoteAddress $ManagementSubnet `
    -Action Allow `
    -Profile Any `
    -Enabled True

# Block common attack ports inbound
$BlockPorts = @(
    @{Port=23;  Name="Block Telnet"},
    @{Port=21;  Name="Block FTP"},
    @{Port=161; Name="Block SNMP"},
    @{Port=162; Name="Block SNMP Trap"},
    @{Port=593; Name="Block RPC over HTTP"},
    @{Port=1433; Name="Block SQL Server (if not SQL role)"},
    @{Port=5900; Name="Block VNC"}
)

foreach ($Rule in $BlockPorts) {
    Remove-NetFirewallRule -DisplayName $Rule.Name -ErrorAction SilentlyContinue
    New-NetFirewallRule `
        -DisplayName $Rule.Name `
        -Direction Inbound `
        -Protocol TCP `
        -LocalPort $Rule.Port `
        -Action Block `
        -Profile Any `
        -Enabled True
    Write-Host "Created block rule: $($Rule.Name)"
}

Registry-Based Hardening for SCHANNEL, SMB, and NTLM

Many critical Windows Server security settings are controlled exclusively through registry values. The following script configures SCHANNEL (disabling weak protocols and cipher suites), SMB (enforcing SMB signing, disabling SMBv1), and NTLM (restricting to NTLMv2 only).

# --- SCHANNEL: Disable weak protocols ---
$SCHANNELBase = "HKLM:SYSTEMCurrentControlSetControlSecurityProvidersSCHANNELProtocols"

$WeakProtocols = @("SSL 2.0","SSL 3.0","TLS 1.0","TLS 1.1")
foreach ($Proto in $WeakProtocols) {
    foreach ($Role in @("Server","Client")) {
        $Key = "$SCHANNELBase$Proto$Role"
        if (-not (Test-Path $Key)) { New-Item -Path $Key -Force | Out-Null }
        Set-ItemProperty -Path $Key -Name "Enabled" -Value 0 -Type DWord
        Set-ItemProperty -Path $Key -Name "DisabledByDefault" -Value 1 -Type DWord
    }
}

# Enable TLS 1.2 and TLS 1.3 explicitly
$StrongProtocols = @("TLS 1.2","TLS 1.3")
foreach ($Proto in $StrongProtocols) {
    foreach ($Role in @("Server","Client")) {
        $Key = "$SCHANNELBase$Proto$Role"
        if (-not (Test-Path $Key)) { New-Item -Path $Key -Force | Out-Null }
        Set-ItemProperty -Path $Key -Name "Enabled" -Value 1 -Type DWord
        Set-ItemProperty -Path $Key -Name "DisabledByDefault" -Value 0 -Type DWord
    }
}

# Disable RC4 cipher
$RC4Key = "HKLM:SYSTEMCurrentControlSetControlSecurityProvidersSCHANNELCiphersRC4 128/128"
if (-not (Test-Path $RC4Key)) { New-Item -Path $RC4Key -Force | Out-Null }
Set-ItemProperty -Path $RC4Key -Name "Enabled" -Value 0 -Type DWord

# --- SMB Hardening ---
# Disable SMBv1
Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force
Set-SmbServerConfiguration -EnableSMB2Protocol $true -Force

# Require SMB signing on server
Set-SmbServerConfiguration -RequireSecuritySignature $true -Force
Set-SmbServerConfiguration -EnableSecuritySignature $true -Force

# Require SMB signing on client connections
Set-ItemProperty -Path "HKLM:SYSTEMCurrentControlSetServicesLanmanWorkstationParameters" `
    -Name "RequireSecuritySignature" -Value 1 -Type DWord

# --- NTLM Hardening ---
$LSAKey = "HKLM:SYSTEMCurrentControlSetControlLsa"

# LM Authentication Level: 5 = NTLMv2 only, refuse LM/NTLMv1
Set-ItemProperty -Path $LSAKey -Name "LmCompatibilityLevel" -Value 5 -Type DWord

# Require NTLMv2 session security
Set-ItemProperty -Path "HKLM:SYSTEMCurrentControlSetControlLsaMSV1_0" `
    -Name "NtlmMinClientSec" -Value 537395200 -Type DWord
Set-ItemProperty -Path "HKLM:SYSTEMCurrentControlSetControlLsaMSV1_0" `
    -Name "NtlmMinServerSec" -Value 537395200 -Type DWord

# Disable LM hash storage
Set-ItemProperty -Path $LSAKey -Name "NoLMHash" -Value 1 -Type DWord

Write-Host "SCHANNEL, SMB, and NTLM hardening applied."

Configuring Audit Policies

Windows advanced audit policies control which security events are written to the Security event log. The default audit configuration on Windows Server 2022 is minimal and insufficient for security monitoring. Comprehensive audit policies are required for effective threat detection and forensic investigation.

# Configure Advanced Audit Policies via auditpol
# Using Start-Process to capture output and handle elevation requirements

$AuditSettings = @(
    # Account Logon
    "/set /subcategory:`"Credential Validation`" /success:enable /failure:enable",
    "/set /subcategory:`"Kerberos Authentication Service`" /success:enable /failure:enable",
    "/set /subcategory:`"Kerberos Service Ticket Operations`" /success:enable /failure:enable",
    # Account Management
    "/set /subcategory:`"User Account Management`" /success:enable /failure:enable",
    "/set /subcategory:`"Security Group Management`" /success:enable /failure:enable",
    "/set /subcategory:`"Computer Account Management`" /success:enable /failure:enable",
    # Logon/Logoff
    "/set /subcategory:`"Logon`" /success:enable /failure:enable",
    "/set /subcategory:`"Logoff`" /success:enable /failure:disable",
    "/set /subcategory:`"Account Lockout`" /success:enable /failure:enable",
    "/set /subcategory:`"Special Logon`" /success:enable /failure:disable",
    # Object Access
    "/set /subcategory:`"File System`" /success:disable /failure:enable",
    "/set /subcategory:`"Registry`" /success:disable /failure:enable",
    # Privilege Use
    "/set /subcategory:`"Sensitive Privilege Use`" /success:enable /failure:enable",
    # Process Tracking
    "/set /subcategory:`"Process Creation`" /success:enable /failure:disable",
    # Policy Change
    "/set /subcategory:`"Audit Policy Change`" /success:enable /failure:enable",
    "/set /subcategory:`"Authentication Policy Change`" /success:enable /failure:enable",
    # System
    "/set /subcategory:`"Security System Extension`" /success:enable /failure:enable",
    "/set /subcategory:`"System Integrity`" /success:enable /failure:enable"
)

foreach ($Setting in $AuditSettings) {
    $Result = Start-Process -FilePath "auditpol.exe" -ArgumentList $Setting `
        -Wait -PassThru -WindowStyle Hidden
    if ($Result.ExitCode -ne 0) {
        Write-Warning "auditpol failed for: $Setting"
    }
}

Write-Host "Audit policies configured."
# Verify
Start-Process -FilePath "auditpol.exe" -ArgumentList "/get /category:*" -Wait -NoNewWindow

Configuring Account Policies

Account lockout and password policies should be enforced to limit brute-force attack viability. On domain members, these settings are typically controlled by Default Domain Policy, but on standalone servers or for local accounts, configure them directly:

# Account lockout: lock after 5 bad attempts, 30 min window, 30 min lockout duration
net accounts /lockoutthreshold:5
net accounts /lockoutwindow:30
net accounts /lockoutduration:30

# Password policy
net accounts /minpwlen:14
net accounts /maxpwage:90
net accounts /minpwage:1
net accounts /uniquepw:24

Write-Host "Account policies applied:"
net accounts

Idempotent Hardening Script Structure

A production hardening script should be idempotent — running it twice should produce the same result as running it once, with no errors and no unintended state changes. Structure your scripts using Set-ItemProperty with explicit type declarations (always specify -Type DWord or -Type String), use Test-Path before creating registry keys with New-Item, use -Force on Set-Service, and use -ErrorAction SilentlyContinue with explicit existence checks rather than try/catch for performance at scale.

Wrap the entire hardening script in a function with a -WhatIf parameter to allow dry-run previews before applying changes in production.

Generating a Hardening Report with PowerShell

After applying hardening, generate a compliance verification report to confirm all settings were applied correctly:

$Report = [ordered]@{}

# Check SMB settings
$SmbConfig = Get-SmbServerConfiguration
$Report["SMBv1 Disabled"] = -not $SmbConfig.EnableSMB1Protocol
$Report["SMB Signing Required"] = $SmbConfig.RequireSecuritySignature

# Check NTLM level
$LmLevel = (Get-ItemProperty "HKLM:SYSTEMCurrentControlSetControlLsa" -Name "LmCompatibilityLevel" -ErrorAction SilentlyContinue).LmCompatibilityLevel
$Report["NTLMv2 Only (Level 5)"] = ($LmLevel -eq 5)

# Check LM hash disabled
$NoLMHash = (Get-ItemProperty "HKLM:SYSTEMCurrentControlSetControlLsa" -Name "NoLMHash" -ErrorAction SilentlyContinue).NoLMHash
$Report["LM Hash Disabled"] = ($NoLMHash -eq 1)

# Check dangerous services
$DangerousServices = @("RemoteRegistry","Spooler")
foreach ($svc in $DangerousServices) {
    $s = Get-Service -Name $svc -ErrorAction SilentlyContinue
    $Report["Service Disabled: $svc"] = ($s -and $s.StartType -eq "Disabled")
}

# Check firewall is enabled
$FWProfiles = Get-NetFirewallProfile
foreach ($profile in $FWProfiles) {
    $Report["Firewall Enabled ($($profile.Name))"] = $profile.Enabled
}

# Output report
Write-Host "`n=== HARDENING COMPLIANCE REPORT ===" -ForegroundColor Cyan
$PassCount = 0; $FailCount = 0
foreach ($Check in $Report.GetEnumerator()) {
    if ($Check.Value -eq $true) {
        Write-Host "  [PASS] $($Check.Key)" -ForegroundColor Green
        $PassCount++
    } else {
        Write-Host "  [FAIL] $($Check.Key)" -ForegroundColor Red
        $FailCount++
    }
}
Write-Host "`nTotal: $PassCount passed, $FailCount failed" -ForegroundColor Yellow

Creating a DSC Hardening Configuration

PowerShell Desired State Configuration (DSC) provides a declarative approach to hardening that continuously enforces the desired state, automatically correcting drift. Install the required DSC resources and create a configuration that encodes your hardening baseline:

# Install required DSC resources
Install-Module -Name SecurityPolicyDsc -Force -AllowClobber
Install-Module -Name NetworkingDsc -Force -AllowClobber
Install-Module -Name ComputerManagementDsc -Force -AllowClobber

# Define the DSC configuration
Configuration WindowsServer2022Hardening {
    Import-DscResource -ModuleName PSDesiredStateConfiguration
    Import-DscResource -ModuleName NetworkingDsc
    Import-DscResource -ModuleName ComputerManagementDsc

    Node "localhost" {
        # Disable Remote Registry service
        Service DisableRemoteRegistry {
            Name        = "RemoteRegistry"
            State       = "Stopped"
            StartupType = "Disabled"
        }

        # Disable Print Spooler
        Service DisableSpooler {
            Name        = "Spooler"
            State       = "Stopped"
            StartupType = "Disabled"
        }

        # Enable Windows Firewall for all profiles
        FirewallProfile EnableDomainFirewall {
            Name    = "Domain"
            Enabled = "True"
        }
        FirewallProfile EnablePrivateFirewall {
            Name    = "Private"
            Enabled = "True"
        }
        FirewallProfile EnablePublicFirewall {
            Name    = "Public"
            Enabled = "True"
        }

        # Registry: Disable LM hash storage
        Registry DisableLMHash {
            Key       = "HKEY_LOCAL_MACHINESYSTEMCurrentControlSetControlLsa"
            ValueName = "NoLMHash"
            ValueData = "1"
            ValueType = "Dword"
            Ensure    = "Present"
        }

        # Registry: NTLMv2 only
        Registry NTLMv2Only {
            Key       = "HKEY_LOCAL_MACHINESYSTEMCurrentControlSetControlLsa"
            ValueName = "LmCompatibilityLevel"
            ValueData = "5"
            ValueType = "Dword"
            Ensure    = "Present"
        }
    }
}

# Compile the configuration
WindowsServer2022Hardening -OutputPath "C:DSCHardening"

# Apply the configuration
Start-DscConfiguration -Path "C:DSCHardening" -Wait -Verbose -Force

# Set to continuous enforcement mode
Set-DscLocalConfigurationManager -Path "C:DSCHardening" -Force

With DSC in ApplyAndAutoCorrect mode, the Local Configuration Manager will periodically check the server’s actual state against the DSC configuration and automatically remediate any detected drift, providing continuous hardening enforcement rather than a one-time application.