Clicking through the Windows Defender Firewall GUI does not scale, does not repeat, and leaves nothing a supervisor can inspect. Every rule you set by hand is a rule nobody else can review, diff, or rebuild.
On a single Windows Server 2022 box, the wf.msc console is fine. Across a fleet, or in any regulated estate where you have to show how a host was configured and prove it has not drifted, the console is a liability. The fix is to treat the firewall as code: a PowerShell script you run at provisioning, commit to source control, and diff on every change. This walks through the PowerShell 7 equivalents of the clicks — profile defaults, scoped inbound rules, and verification — and the one gotcha that catches people the first time they run this on PowerShell 7 rather than the built-in 5.1.
The module is not native to PowerShell 7
The cmdlets you need live in the NetSecurity module. That module is a CDXML wrapper over Windows Management Instrumentation, and it is not natively loaded in PowerShell 7. When you run New-NetFirewallRule in pwsh, PowerShell 7 quietly imports the module through the Windows PowerShell Compatibility feature: it spins up a background Windows PowerShell 5.1 session called WinPSCompatSession and proxies the commands into it by implicit remoting. It works, but you should know it is happening. The objects that come back are deserialised, some interactive niceties are absent, and the first call in a session carries the cost of standing that compatibility session up.
Free · 4 minutes
Would you survive contact with a determined attacker — or an auditor?
Fourteen questions on access, patching, detection, and recovery — the basics that prevent most real incidents, and the ones most often assumed rather than verified. Banded finding on screen, full sheet by email.
Nothing here requires you to do anything special — the commands below run unchanged in pwsh 7.4. Just do not be surprised when Get-Module NetSecurity reports it was loaded through the compatibility session, and run the script in an elevated console, because changing firewall state needs administrator rights either way.
Set the profile defaults first
Before you add a single rule, decide the default posture for each of the three profiles — Domain, Private, and Public. This is the “block all inbound unless a rule permits it” decision, and it is the one the GUI buries three dialogs deep. Set-NetFirewallProfile sets it in one call.
# Deny-by-default inbound across all three profiles, allow outbound.
# Log dropped packets so you can see what the default action caught.
Set-NetFirewallProfile -Name Domain,Private,Public `
-Enabled True `
-DefaultInboundAction Block `
-DefaultOutboundAction Allow `
-LogBlocked True `
-LogFileName '%SystemRoot%\System32\LogFiles\Firewall\pfirewall.log' `
-LogMaxSizeKilobytes 16384
DefaultInboundAction Block is what makes the firewall deny-by-default: any inbound packet that matches no allow rule is dropped. That is already the out-of-the-box behaviour on a domain-joined server, but setting it explicitly means the script asserts the posture rather than assuming it. DefaultOutboundAction Allow keeps outbound open; if your environment demands egress filtering, set it to Block and be prepared to write outbound allow rules for everything the host legitimately talks to, which is a larger undertaking than it sounds. Turning on LogBlocked gives you the dropped-packet log at the path shown, which is the first place to look when a rule you expected to permit traffic does not.
Scoped inbound rules, not open ports
The single most common mistake in the GUI is opening a port to the world when you meant to open it to a subnet. New-NetFirewallRule takes a -RemoteAddress that scopes the rule to a known source, and that is where least privilege lives. The same discipline that stops you handing out unscoped credentials applies to network reach — the thinking behind scoping API keys so they are not a liability is exactly the thinking that should scope a firewall rule.
Rather than a scatter of one-off New-NetFirewallRule lines, declare the rules as data and loop over them, and make the whole thing idempotent by clearing the rule group before you re-create it. Run the script twice and you get the same result, not duplicate rules.
$group = 'Provisioning-Baseline'
# Idempotency: remove this group's rules before re-declaring them,
# so a second run does not stack duplicates.
Get-NetFirewallRule -Group $group -ErrorAction SilentlyContinue |
Remove-NetFirewallRule
# The rules, as data. RemoteAddress 'Any' is deliberate and reviewable;
# the management ports are scoped to a known subnet.
$rules = @(
@{ DisplayName = 'Allow HTTPS inbound'; LocalPort = 443; RemoteAddress = 'Any' }
@{ DisplayName = 'Allow RDP from management'; LocalPort = 3389; RemoteAddress = '10.20.0.0/24' }
@{ DisplayName = 'Allow WinRM from management'; LocalPort = 5985; RemoteAddress = '10.20.0.0/24' }
)
foreach ($r in $rules) {
New-NetFirewallRule @r `
-Direction Inbound `
-Action Allow `
-Protocol TCP `
-Profile Domain `
-Group $group `
-Enabled True | Out-Null
}
Two details earn their place. The -Group tag is what makes the set removable and queryable as a unit — it is the handle you use to tear the whole baseline down and rebuild it. And splatting each hashtable with @r while adding the shared parameters inline keeps the rule data readable: the thing that varies per rule is a row in the table, and the thing that is constant is written once. RemoteAddress accepts CIDR notation such as 10.20.0.0/24, a range, or keywords like LocalSubnet; the point is that “who can reach this port” is an explicit, reviewed value in the file, not an omission that silently means everyone.
Verify what you actually built
A rule object and its port and address are separate things in this model — the port lives on a port filter, the scope on an address filter — so a plain Get-NetFirewallRule shows you the rule names but not the ports or subnets. To confirm the scope you built, join the filters back on:
Get-NetFirewallRule -Group 'Provisioning-Baseline' |
ForEach-Object {
$port = $_ | Get-NetFirewallPortFilter
$addr = $_ | Get-NetFirewallAddressFilter
[pscustomobject]@{
Rule = $_.DisplayName
Enabled = $_.Enabled
LocalPort = $port.LocalPort
RemoteAddress = $addr.RemoteAddress
}
} | Format-Table -AutoSize
That table is the evidence artefact: it says, per rule, what is open and to whom. It is the thing to paste into a change record or hand to an auditor, and it is trivial to regenerate on any host to check the live state matches the script.
The script is the artefact, not the .wfw export
There is a temptation to reach for netsh advfirewall export "C:\baseline.wfw" to capture the configuration. Do not treat that as your source of truth. The .wfw file is an opaque binary blob: you cannot read it, you cannot diff it in a pull request, and a one-character change of intent produces a wholly different file with no readable delta. It is a backup format, not a configuration format — and Microsoft has signalled the netsh advfirewall surface as legacy in favour of the firewall APIs.
The declarative script above is the artefact worth keeping. It is the source of truth, it reads as intent, and a reviewer can see in a diff that a management port moved from 10.20.0.0/24 to 10.0.0.0/8 and ask why. Commit it, run it from your provisioning step, and gate changes to it the same way you gate any other config — the argument for a pre-commit baseline that actually runs in CI applies to firewall rules as much as to application code. When the firewall is a reviewed, versioned file that rebuilds a host identically every time, you have both the scale the GUI cannot give you and the auditable trail a regulated estate is going to be asked for — the kind of change-tracking that also feeds honest deployment and change-failure metrics.
A firewall you configured by clicking is a firewall nobody can reconstruct. A firewall you configured in a committed script is one anybody on the team can read, rebuild, and prove.
Free interactive tool
Website compliance checklist
What your site has to do, based on what it actually does
Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.
Everything that applies
Ordered by what to do first: legal requirements you can close quickly, then larger pieces of work, then what is expected rather than required. Not exhaustive, and not a legal audit.
Dated PDF, yours to keep or circulate.
Most technology problems are not technology problems. They are control problems.
The systems exist. The investment has been made. The question is whether leadership can understand, direct, evidence, and sustain what those systems produce. Find out where control exists — and where it only appears to.