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.PSVersion to confirm)
  • The SmbShare and Defender modules 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.