New-NetFirewallRule creates an inbound or outbound Windows Firewall rule and attaches it to a policy store, which is the scripted replacement for clicking through Windows Firewall with Advanced Security. One line opens a port, and the rule survives a reboot.
The part that costs an afternoon is reading the rule back. Get-NetFirewallRule returns the rule you just created, and the port is not on it. Neither is the remote address, nor the program path. The reference page says so in one sentence on fourteen separate parameters, and that sentence is easy to miss while you are busy copying a syntax example.
This article is built on five boundaries between what you write and what you can read back: the name boundary, the condition boundary, the default boundary, the store boundary and the no-parameter boundary. Each one is stated from Microsoft documentation and, where the behaviour is a PowerShell rule rather than a firewall rule, measured in a shell rather than asserted. The rule created in the first example is removed in the last one, and the removal proves itself.
Applies to: Windows 10 / 11 and Windows Server 2016 / 2019 / 2022 / 2025, with the NetSecurity module, which ships with the operating system.
Quick answer
Create one inbound TCP rule with a name you chose, read its port back through the port filter, and remove it. These three commands are the whole working loop, and every example below is one of them in more detail. Run them from an elevated PowerShell session.
# -Name is the identifier you will use later; -DisplayName is what the GUI shows.
New-NetFirewallRule -Name 'ZT-Demo-TCP-9443-In' -DisplayName 'ZT Demo TCP 9443 In' `
-Direction Inbound -Action Allow -Protocol TCP -LocalPort 9443 -Profile Domain,Private
# The port is NOT on the rule object, so ask the port filter for it.
Get-NetFirewallRule -Name 'ZT-Demo-TCP-9443-In' | Get-NetFirewallPortFilter
# Remove it again. An empty result from the Get is the confirmation.
Remove-NetFirewallRule -Name 'ZT-Demo-TCP-9443-In'
Get-NetFirewallRule -Name 'ZT-Demo-TCP-9443-In' -ErrorAction SilentlyContinue
What New-NetFirewallRule does
The cmdlet writes a rule into a policy store and, at the same time, writes a set of filter objects that hold the rule’s conditions. The reference page states the relationship directly: “Filter objects, such as NetFirewallAddressFilter or NetFirewallApplicationFilter, are created with each firewall rule. The filter objects and rules are always one-to-one and are managed automatically.” Those filter objects are the reason the rest of this article exists.
The parameters that matter on a first pass, with the defaults the reference page records for each:
| Parameter | What it sets | Default |
|---|---|---|
-DisplayName | The localized, user-facing name. Required. | none, it is mandatory |
-Name | The identifier, unique per policy store. Aliased ID. | a randomly assigned value |
-Direction | Inbound or Outbound | Inbound |
-Action | Allow or Block | Allow |
-Enabled | True or False, and it is not a Boolean | True |
-Profile | Any, Domain, Private, Public or NotApplicable | Any |
-Protocol | TCP, UDP, ICMPv4, ICMPv6 or a number from 0 to 255 | Any |
-LocalPort | A port, a range, a list, or a keyword such as Any | none documented |
-PolicyStore | Which store the rule is written to | PersistentStore |
Allow, True and Any. A command that names only -DisplayName, -Protocol and -LocalPort creates an enabled inbound allow rule that applies on every profile, including Public. Boundary 3 writes that out in full.
-LocalPort is not valid on its own. The -Protocol reference text is explicit: “If a port number is identified by using numeric values (80, 443, 8080, etc.), then this parameter must be set to TCP or UDP.” Without -Protocol, the documented -LocalPort values are the keywords RPC, RPCEPMap, Teredo, IPHTTPSIn, IPHTTPSOut and Any.
Before the first example
Four things need to be true before the first command. Each one is a single command, and each one answers a question that otherwise turns into a confusing error later.
1. An elevated session. Firewall rules are machine configuration, so expect to need a full administrator token. The reference pages state no permission requirement at all, so treat this as operational practice rather than documented behaviour. The check that actually works is the mandatory integrity level in your own access token rather than group membership: S-1-16-12288 means elevated, S-1-16-8192 means an administrator account running with a filtered token.
# High Mandatory Level is S-1-16-12288. A filtered admin token shows S-1-16-8192.
whoami /groups | Select-String 'S-1-16-12288'
The difference between those two SIDs, and why an account in the Administrators group can still be refused, is the subject of reading your access token with whoami.
2. The module is present. New-NetFirewallRule ships with Windows in the NetSecurity module. If this returns nothing you are on a Windows edition or a PowerShell host that cannot see it.
# ListAvailable looks on disk rather than at what is already imported.
Get-Module NetSecurity -ListAvailable | Select-Object Name, Version, ModuleType
3. A baseline count. The last example proves the cleanup by comparing the rule count with the number taken now. Save it in $before and leave the variable alone.
# The count before anything is created, so the removal can be proved later.
$before = (Get-NetFirewallRule).Count
$before
4. Know which profile is active. A rule scoped to Domain does nothing on a machine whose active profile is Public. One profile is active at a time.
# Enabled here is the profile's own firewall state, not a rule's state.
Get-NetFirewallProfile | Select-Object Name, Enabled, DefaultInboundAction
Example 1: one rule, and a name you chose
The problem: an application on SRV-PROD-01 listens on TCP 9443 and remote clients time out. The port has to be opened by a rule that a colleague can find again next month.
The solution: create the rule with both names set explicitly, so the rule has a stable identifier as well as a readable label.
# -Name is the identifier: unique per store, and what -Name queries match later.
# -DisplayName is the localized label, and it is the only mandatory name parameter.
# -Profile Domain,Private deliberately leaves the Public profile alone.
New-NetFirewallRule -Name 'ZT-Demo-TCP-9443-In' `
-DisplayName 'ZT Demo TCP 9443 In' `
-Description 'Opens TCP 9443 for the application on SRV-PROD-01' `
-Direction Inbound `
-Action Allow `
-Protocol TCP `
-LocalPort 9443 `
-Profile Domain,Private `
-Enabled True
The cmdlet returns the rule object it created. The reference page publishes no sample of that output, so none is shown here; what it contains is the subject of the next example, and what it does not contain is the subject of the one after.
-Name. The parameter is optional and its documented default is a randomly assigned value, so the rule gets a GUID for an identifier. Everything still works, and then every later script has to find the rule by its localized display name instead. Set -Name on anything you intend to manage again.
Example 2: read the rule back, ports included
The problem: the rule exists, and Get-NetFirewallRule prints a block of properties that does not mention 9443 anywhere.
The solution: pipe the rule into the port filter. The rule and its filter objects are one-to-one, so this returns exactly one filter for one rule.
# The rule itself: names, direction, action, profile, status.
Get-NetFirewallRule -Name 'ZT-Demo-TCP-9443-In' | Format-List -Property *
# The conditions, which live on separate objects and have to be asked for.
Get-NetFirewallRule -Name 'ZT-Demo-TCP-9443-In' | Get-NetFirewallPortFilter
Get-NetFirewallRule -Name 'ZT-Demo-TCP-9443-In' | Get-NetFirewallAddressFilter
The split is not arbitrary and it is not hidden. The Get-NetFirewallRule description states it plainly: “When running the cmdlet with the Get verb for any firewall, IPsec, or main mode rule, notice that the common conditions like addresses or ports do not appear. These conditions are represented in separate objects called filters.”
Which cmdlet answers which question:
| You want to read | Ask this cmdlet | Properties it carries |
|---|---|---|
| Port and protocol | Get-NetFirewallPortFilter | Protocol, LocalPort, RemotePort, IcmpType, DynamicTransport |
| Local and remote address | Get-NetFirewallAddressFilter | LocalAddress, RemoteAddress |
| Program or package | Get-NetFirewallApplicationFilter | Program, Package |
| Service short name | Get-NetFirewallServiceFilter | Service |
Example 3: find every rule that opens a port
The problem: a port scan of SRV-PROD-01 reports 9443 open and nobody remembers which rule is responsible.
The solution: start from the port filters, narrow them, then pipe them back into Get-NetFirewallRule. The pipeline runs in the other direction from Example 2, and the filter cmdlets are documented to support exactly this.
# Start with the filters, not the rules, because only filters know about ports.
Get-NetFirewallPortFilter |
Where-Object LocalPort -eq '9443' |
Get-NetFirewallRule |
Select-Object Name, DisplayName, Enabled, Direction, Action, Profile
That command is correct, it is the comparison Microsoft’s own Get-NetFirewallPortFilter example uses, and it is incomplete. LocalPort is a string array whose documented shapes include a single port, a range, a comma-separated list and the keyword Any. A string comparison answers a question about text, not a question about which ports a rule opens.
Here are the documented value shapes as a set of stand-in filter objects, with five comparisons run against them. This runs in any PowerShell 7 session, on Windows or not, because nothing in it touches the firewall.
# The LocalPort value shapes the reference page documents: a port number, a
# port range, a comma-separated list, the Any keyword and a protocol keyword.
# ZT-Near is the near miss that a regex comparison finds by accident.
$filters = @(
[pscustomobject]@{ Rule = 'ZT-Single'; LocalPort = @('9443') }
[pscustomobject]@{ Rule = 'ZT-Range'; LocalPort = @('9400-9450') }
[pscustomobject]@{ Rule = 'ZT-List'; LocalPort = @('12345','9400-9450') }
[pscustomobject]@{ Rule = 'ZT-Any'; LocalPort = @('Any') }
[pscustomobject]@{ Rule = 'ZT-RPC'; LocalPort = @('RPC') }
[pscustomobject]@{ Rule = 'ZT-Near'; LocalPort = @('94430') }
)
function Show-Hits ($Label, $Test) {
# LocalPort is an array, so -eq filters it rather than returning a Boolean.
$names = ($filters | Where-Object $Test | ForEach-Object Rule) -join ','
'{0,-18}{1}' -f $Label, $(if ($names) { $names } else { '(nothing)' })
}
Show-Hits '-eq 9443' { $_.LocalPort -eq '9443' }
Show-Hits '-contains 9443' { $_.LocalPort -contains '9443' }
Show-Hits '-match 9443' { $_.LocalPort -match '9443' }
Show-Hits '-eq 9410' { $_.LocalPort -eq '9410' }
Show-Hits '-match 9410' { $_.LocalPort -match '9410' }
-eq 9443 ZT-Single
-contains 9443 ZT-Single
-match 9443 ZT-Single,ZT-Near
-eq 9410 (nothing)
-match 9410 (nothing)
Read the last two lines first. Port 9410 is inside the range 9400-9450, so ZT-Range, ZT-List and ZT-Any all open it, and both string comparisons return nothing at all. No error, no warning, an empty result that looks exactly like a clean machine.
Four of the six stand-ins do open 9443: the single port, the range, the list and Any. Against that, here is what each comparison actually returned.
| Comparison | Rows returned | What it missed | What it added |
|---|---|---|---|
-eq '9443' | 1 of the 4 rules that open 9443 | ZT-Range, ZT-List, ZT-Any | nothing |
-contains '9443' | 1 of the 4 | the same three | nothing |
-match '9443' | 2 rows | the same three | ZT-Near, which opens 94430 and not 9443 |
-eq '9410' | 0 rows | all three rules that open 9410 | nothing |
-match '9410' | 0 rows | all three rules that open 9410 | nothing |
The fix is to expand the documented shapes before comparing. This function walks the array, expands a range into its bounds, treats Any as a match for everything, and is short enough to paste into a diagnostic session. It continues in the same session as the block above, so $filters is still in scope.
function Test-RuleOpensPort {
param([string[]]$LocalPort, [int]$Port)
foreach ($entry in $LocalPort) {
# Any means every port, and it is the shape a string compare never finds.
if ($entry -eq 'Any') { return $true }
# A range is two numbers and a hyphen, so it has to be expanded.
if ($entry -match '^(\d+)-(\d+)$') {
if ($Port -ge [int]$Matches[1] -and $Port -le [int]$Matches[2]) { return $true }
continue
}
if ($entry -match '^\d+$' -and [int]$entry -eq $Port) { return $true }
}
return $false
}
foreach ($p in 9443, 9410, 12345, 80) {
$names = ($filters | Where-Object { Test-RuleOpensPort $_.LocalPort $p } | ForEach-Object Rule) -join ','
'port {0,-8}{1}' -f $p, $names
}
port 9443 ZT-Single,ZT-Range,ZT-List,ZT-Any
port 9410 ZT-Range,ZT-List,ZT-Any
port 12345 ZT-List,ZT-Any
port 80 ZT-Any
RPC, RPCEPMap, Teredo or PlayToDiscovery. Those are documented -LocalPort keywords rather than numbers, so there is nothing for a script to expand them into. Treat a rule carrying one of them as a rule you have to read by hand.
Example 4: scope the rule to a profile and a remote address
The problem: the rule from Example 1 allows 9443 from anywhere on the domain and private networks. It should only accept connections from the local subnet.
The solution: Set-NetFirewallRule changes an existing rule in place, and LocalSubnet is one of the documented keywords the address filter understands.
# Narrow the rule without recreating it. -Name matches the identifier from Example 1.
Set-NetFirewallRule -Name 'ZT-Demo-TCP-9443-In' -RemoteAddress LocalSubnet
# Confirm through the address filter, because RemoteAddress is not on the rule.
Get-NetFirewallRule -Name 'ZT-Demo-TCP-9443-In' | Get-NetFirewallAddressFilter |
Select-Object LocalAddress, RemoteAddress
Windows itself uses this pattern for its own remoting rules, and the Enable-PSRemoting documentation is the clearest published example of it. On server editions, enabling remoting creates a public-profile rule that accepts connections only from the same local subnet; on client editions the same restriction needs -SkipNetworkProfileCheck. Lifting it is a one-line Set-NetFirewallRule.
# Microsoft's own example. Enable remoting, then widen the public-profile rule.
Enable-PSRemoting -SkipNetworkProfileCheck -Force
Set-NetFirewallRule -Name 'WINRM-HTTP-In-TCP' -RemoteAddress Any
That page is also a good illustration of why you should never hardcode a Windows rule name. Its own guidance is this: “The name of the firewall rule can be different for different versions of Windows. Use Get-NetFirewallRule to see a list of rules.” It then publishes the capture below from a real machine. The machine is Microsoft’s, not the SRV-PROD-01 used elsewhere in this article.
Get-NetFirewallRule -Name 'WINRM*' | Select-Object -Property Name
Name
----
WINRM-HTTP-In-TCP-NoScope
WINRM-HTTP-In-TCP
WINRM-HTTP-Compat-In-TCP-NoScope
WINRM-HTTP-Compat-In-TCP
Set-NetFirewallRule -Name 'WINRM-HTTP-In-TCP' -RemoteAddress Any and its Notes section says Set-NetFirewallRule -Name "WINRM-HTTP-In-TCP-PUBLIC" -RemoteAddress Any. The capture above is the third artefact that settles it: the four names that machine reported include WINRM-HTTP-In-TCP and do not include a -PUBLIC variant. Run the wildcard query on your own machine before you paste either line.
Why remoting cares about all of this, and which ports each transport actually uses, is covered in Enter-PSSession and WinRM, where -ComputerName is not remoting.
Example 5: remove exactly the rule you created
The problem: the demonstration is finished and the machine should be back where it started, with no leftover rule and no doubt about it.
The solution: remove the rule by its identifier, then prove the removal twice: once by asking for the rule and getting nothing, once by comparing the rule count with the baseline from the prerequisites.
# Remove by -Name, which matches one rule per store and nothing else.
Remove-NetFirewallRule -Name 'ZT-Demo-TCP-9443-In'
# An empty result here is the pass. It reads like a failure, and it is not.
Get-NetFirewallRule -Name 'ZT-Demo-TCP-9443-In' -ErrorAction SilentlyContinue
# And the count is back to the baseline taken before Example 1.
$after = (Get-NetFirewallRule).Count
'before={0} after={1} clean={2}' -f $before, $after, ($before -eq $after)
-ErrorAction SilentlyContinue is there on purpose. A query for a name that is gone is not guaranteed to be a quiet empty result, and a red error block at the end of a successful cleanup is the thing most likely to be misread as a failure. The parameter makes the confirmation step read the same way whichever the cmdlet does.
If a rule should stop working but has to stay on the machine, Disable-NetFirewallRule is the cmdlet, not Remove-NetFirewallRule. The reference page draws the line: a disabled rule “will not actively modify computer behavior, but it still exists on the computer or in a GPO so it can be re-enabled.”
Boundary 1: the display name is not the name
Two name parameters, two different jobs, and the required one is the wrong one to build a script on.
-DisplayName | -Name | |
|---|---|---|
| Required on create | yes | no |
| Default | none | a randomly assigned value |
| Unique | not enforced | one per policy store |
| Locale | documented as locale-dependent | not described as localized |
| Documented role | the localized, user-facing name | the identifier, like a file name |
| Behaviour on GPO merge | no documented merge behaviour | same name overwrites, so only one survives |
The reference page is explicit about which one a script should use: “When writing scripts in multi-lingual environments, the Name parameter should be used instead, where the default value is a randomly assigned value.” It is equally explicit about what -Name behaves like: “This parameter acts just like a file name, in that only one rule with a given name may exist in a policy store at a time.”
Remove-NetFirewallRule -DisplayName can match more than one rule and remove all of them. The same is true of Set-NetFirewallRule -DisplayName. Query first with Get-NetFirewallRule -DisplayName and count the rows before you write anything.
Boundary 2: the conditions are not on the rule
This is the boundary the whole article is arranged around, and it is stated fourteen times on one page. Fourteen of the thirty-nine parameters of New-NetFirewallRule carry the same sentence, and between them they point at seven different filter cmdlets: “Querying for rules with this parameter can only be performed using filter objects.”
| Parameter you wrote | Cmdlet that can read it back |
|---|---|
-LocalPort, -RemotePort, -Protocol | Get-NetFirewallPortFilter |
-LocalAddress, -RemoteAddress | Get-NetFirewallAddressFilter |
-Program, -Package | Get-NetFirewallApplicationFilter |
-Service | Get-NetFirewallServiceFilter |
-InterfaceAlias | Get-NetFirewallInterfaceFilter |
-InterfaceType | Get-NetFirewallInterfaceTypeFilter |
-LocalUser, -RemoteUser, -RemoteMachine, -OverrideBlockRules | Get-NetFirewallSecurityFilter |
What makes this expensive is not the split. It is that querying the rule object for a condition fails silently. The rule object has no LocalPort property, and Where-Object on a property that is not there returns nothing and raises nothing.
The script below builds a stand-in rule object from the fifteen properties the documented ByQuery parameter set of Get-NetFirewallRule can query on, sets $ErrorActionPreference to Stop so that no error can be swallowed by a preference, and then asks for a port three different ways.
# A stand-in for what Get-NetFirewallRule returns, built only from the
# properties its documented ByQuery parameter set can query on. Nothing here
# is a port, an address or a program: those live on the filter objects.
$rule = [pscustomobject]@{
Name = 'ZT-Demo-TCP-9443-In'
DisplayName = 'ZT Demo TCP 9443 In'
Description = $null
DisplayGroup = $null
Group = $null
Enabled = 'True'
Profile = 'Domain, Private'
Platform = @()
Direction = 'Inbound'
Action = 'Allow'
EdgeTraversalPolicy = 'Block'
PrimaryStatus = 'OK'
Status = 'The rule was parsed successfully from the store.'
PolicyStoreSource = 'PersistentStore'
PolicyStoreSourceType = 'Local'
}
# Stop makes every non-terminating error terminate, so nothing below is hidden.
$ErrorActionPreference = 'Stop'
$Error.Clear()
# -Property form: reads like a port query, matches nothing, reports nothing.
$hit = @($rule) | Where-Object LocalPort -eq '9443'
'Where-Object LocalPort rows={0} errors={1}' -f @($hit).Count, $Error.Count
# The script-block form behaves exactly the same way.
$hit = @($rule) | Where-Object { $_.LocalPort -eq '9443' }
'Script block rows={0} errors={1}' -f @($hit).Count, $Error.Count
# Select-Object is the only one of the three that complains.
try { $null = @($rule) | Select-Object -ExpandProperty LocalPort }
catch { 'Select-Object ' + $_.Exception.Message }
Where-Object LocalPort rows=0 errors=0
Script block rows=0 errors=0
Select-Object Property "LocalPort" cannot be found.
Zero rows and zero errors, twice, with $ErrorActionPreference set to Stop. The only construct that objects is Select-Object -ExpandProperty, which is the one nobody reaches for when filtering.
Get-NetFirewallRule | Where-Object LocalPort -eq '9443' means the property does not exist, not that the port is closed. The exact same shape of defect, with a service status enum instead of a port, is the centrepiece of Get-Service and Set-Service.
Boundary 3: five defaults you did not type
A short create command is not a narrow rule. Take the shortest command that opens a port and write out what the documented defaults fill in around it.
# What most people paste.
New-NetFirewallRule -DisplayName 'App 9443' -Protocol TCP -LocalPort 9443
# The same command with the five documented defaults written out.
New-NetFirewallRule -DisplayName 'App 9443' -Protocol TCP -LocalPort 9443 `
-Direction Inbound `
-Action Allow `
-Enabled True `
-Profile Any `
-PolicyStore PersistentStore
There is a sixth default and it is the one you cannot write out: -Name, whose documented value is a randomly assigned one. That is Boundary 1, and it is the reason to set the parameter yourself.
Two of the five matter more than the others. -Enabled True means the rule is live the moment the command returns, with no separate enable step. -Profile Any means it is live on the Public profile too, so the port is open on an untrusted network as well as in the office.
There is one wrinkle worth knowing about -Action. Its prose says the acceptable values are Allow or Block and that the default is Allow. The generated parameter table on the same page lists a third accepted value, NotConfigured, and gives the default as None. The prose is the part to trust for behaviour; the table is describing the parameter attribute, and it is where the third enum member shows up.
The -Enabled parameter is not a Boolean
-Enabled looks like a switch and is not one. The reference page is unusually direct about it: “Note, that the type of this parameter is not boolean, therefore $true and $false variables are not acceptable values here. Use "True" and "False" text strings instead.”
The reason is a PowerShell language rule rather than a firewall rule, and it is worth measuring, because the interesting question is whether the wrong value fails loudly or binds to something. The NetSecurity enum type only exists on Windows, so this declares a stand-in carrying the same two member names and binds values to it.
# The NetSecurity enum type exists only on Windows, so this declares a stand-in
# carrying the two member names the -Enabled documentation lists, and binds
# values to it. On Windows the message names the real type instead of ZtEnabled.
Add-Type -TypeDefinition 'public enum ZtEnabled { True = 1, False = 2 }'
function Set-Demo { param([ZtEnabled]$Enabled) $Enabled.ToString() }
'bare True -> ' + (Set-Demo -Enabled True)
"quoted 'false' -> " + (Set-Demo -Enabled 'false')
try { Set-Demo -Enabled $false } catch { 'the $false variable -> ' + $_.Exception.Message }
bare True -> True
quoted 'false' -> False
the $false variable -> Cannot process argument transformation on parameter 'Enabled'. Cannot convert value "False" to type "ZtEnabled". Error: "Invalid cast from 'System.Boolean' to 'ZtEnabled'."
A Boolean cannot be cast to an enum in PowerShell at all, so the binding fails before the cmdlet runs. That is the good outcome: -Enabled $false stops with an argument transformation error rather than binding to whichever member happens to sit at 0 or 1. Quoted strings bind, and the comparison is not case-sensitive, so 'false' works as well as False.
-Profile values with a comma and to include no spaces. Measured against a stand-in flags enum, Domain,Private, Domain, Private and 'Domain,Private' all bind to the same value, and all three print back as Domain, Private with a space. The no-spaces instruction reads like netsh-era advice; at a PowerShell prompt it makes no difference.
Boundary 4: the rule you can see and cannot delete
-PolicyStore defaults to PersistentStore on the create cmdlets, and the filter cmdlet pages state the same default for reading: “Running this cmdlet without specifying the policy store retrieves the persistent store.” The store you write to and the store you read from by default are therefore the same one, and it is not the complete set of rules in force.
Rules in that store are enforced: the reference text says they “are attached to the ActiveStore and activated on the computer immediately.” What the default view does not show is every OTHER rule the machine is enforcing, and that is where a puzzling result comes from.
Four of the documented stores, and what each one holds:
| Store | What is in it | Writable |
|---|---|---|
PersistentStore | rules created locally, by hand or by an installer. The documented default | yes |
ActiveStore | the resultant set of policy: every GPO that applies, plus the local stores | not described as writable; it is a sum |
RSOP | the sum of the GPOs applied to the computer | documented read-only |
SystemDefaults | the shipped state of the rules that come with Windows | documented read-only |
This is what makes a rule look undeletable. A rule delivered by Group Policy is present in ActiveStore and absent from PersistentStore, so it appears in a query against the active store and Remove-NetFirewallRule cannot touch it, because the store it defaults to does not contain it.
# Everything the machine is actually enforcing, including GPO-delivered rules.
Get-NetFirewallRule -PolicyStore ActiveStore |
Select-Object Name, DisplayName, PolicyStoreSource, PolicyStoreSourceType
# Only the rules held locally, which is the set a local remove can act on.
Get-NetFirewallRule -PolicyStore PersistentStore | Measure-Object | Select-Object Count
PolicyStoreSource and PolicyStoreSourceType are the two properties to read. They say where a rule came from, which is the difference between a rule you can fix on the machine and a rule you have to fix in a GPO.
-PolicyStore domain.fqdn.com\GPO_Friendly_Name, and for the local computer -PolicyStore localhost. That is how you edit firewall policy in a GPO from a script rather than from the Group Policy editor.
Boundary 5: the cmdlet that does everything when you ask for nothing
Two cmdlets in this module do something drastic when run with no parameters at all, and only one of the two reference pages warns you.
The Enable-NetFirewallRule page opens its description with a warning in capitals: “IMPORTANT NOTE: Running this cmdlet without parameters enables all Windows Firewall rules on the target computer. Always run this cmdlet with the -WhatIf parameter if you are not targeting a specific Windows Firewall rule or group of rules.”
Remove-NetFirewallRule has no such warning. Instead it has this as the first of its four examples, with a one-line description and nothing else:
Remove-NetFirewallRule
The published description of that example is “This example removes all of the static local firewall rules.” There is no -WhatIf in it and no caution around it. It is the same footgun as the one the other page shouts about, on a cmdlet whose effect cannot be undone by re-running it.
Remove-NetFirewallRule and pressing Return to see the syntax. It does not print help, it deletes every static local rule. Add -WhatIf to any invocation of either cmdlet that is not pinned to a specific -Name, and treat -DisplayName and -Group as wide targets rather than narrow ones.
Enable-NetFirewallRule takes the same treatment for the same reason: its own documented examples show it operating on whole groups and whole policy stores at once.
The same rule in CMD with netsh advfirewall
The netsh advfirewall context does the same job from a plain command prompt, it is shorter to type, and it is the form older runbooks tend to contain. The equivalent of Example 1, in documented syntax:
rem name= is the only name netsh has: there is no separate display name here.
netsh advfirewall firewall add rule name="ZT Demo TCP 9443 In" protocol=TCP dir=in localport=9443 action=allow
rem Read one rule back. verbose is the documented switch for the detailed form.
netsh advfirewall firewall show rule name="ZT Demo TCP 9443 In" verbose
rem Remove it. Matching is by name, so a duplicated name removes all of them at once.
netsh advfirewall firewall delete rule name="ZT Demo TCP 9443 In"
Two differences matter. netsh has one name per rule, so the distinction in Boundary 1 does not exist there. And netsh prints text, so anything built on it is a parsing exercise, while the cmdlets hand you objects with typed properties.
Microsoft’s own position on which to use is on the netsh overview page, as an IMPORTANT block: “It’s recommended that you use Windows PowerShell to manage networking technologies in Windows and Windows Server rather than netsh.”
netsh advfirewall reference page carries a 2025 date stamp. The NetSecurity cmdlet pages it points you towards, including New-NetFirewallRule, carry the same 12/27/2016 date stamp under the Windows Server 2019, 2022 and 2025 monikers. The recommended tool has the older documentation.
The full netsh tour, including the contexts that have nothing to do with the firewall, is in the netsh command in Windows.
Hidden gems
A rule can be found by the program it allows
Get-NetFirewallApplicationFilter takes a -Program parameter in its own right, so you can go from an executable to the rules that mention it without touching a single rule object first.
# Straight from a binary path to the rules that reference it. Substitute a
# real path: a path that no rule mentions returns nothing rather than an error.
Get-NetFirewallApplicationFilter -Program 'C:\Program Files\App\tomcat9.exe' |
Get-NetFirewallRule |
Select-Object Name, DisplayName, Enabled, Action
The profile object is a query handle
Get-NetFirewallProfile does more than report the firewall state. Piping a profile into Get-NetFirewallRule returns the rules scoped to that profile, which is the direct answer to the question of what is open on the Public profile, and it is a documented example on the profile page.
# Every enabled inbound allow rule that applies on the Public profile.
Get-NetFirewallProfile -Name Public | Get-NetFirewallRule |
Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Allow' } |
Select-Object Name, DisplayName
That Where-Object is safe, unlike the one in Boundary 2, because every property it names is on the rule object rather than on a filter. Knowing which properties live where is the skill this cmdlet set asks for.
Wildcards work on -Name, and that is how you audit your own rules
-Name is documented to accept wildcard characters, which is the argument for giving your own rules a common prefix. The rule in this article is named ZT-Demo-TCP-9443-In, and a shared prefix turns both an audit and a rollback into one query.
# One wildcard query lists everything this article created.
Get-NetFirewallRule -Name 'ZT-*' | Select-Object Name, DisplayName, Enabled
# And a rollback into one more. -WhatIf first, every time.
Get-NetFirewallRule -Name 'ZT-*' | Remove-NetFirewallRule -WhatIf
Copy-NetFirewallRule moves a rule between stores
Developing firewall policy locally and then promoting it is a documented workflow. Copy-NetFirewallRule with -NewPolicyStore writes the rule into a GPO, and the Set-NetFirewallRule page’s own guidance is to remove the local copy afterwards so the two cannot conflict.
# Promote a locally developed rule into a domain GPO, then drop the local copy.
Copy-NetFirewallRule -Name 'ZT-Demo-TCP-9443-In' -NewPolicyStore 'corp.contoso.com\FirewallPolicy'
Remove-NetFirewallRule -Name 'ZT-Demo-TCP-9443-In'
Where this matters
Opening a port for an application rollout. One scripted rule with a chosen -Name is auditable and removable; a rule clicked into the GUI with a localized display name is neither.
Proving to a security review what is open. The answer has to come from the port and address filters, because the rule objects alone cannot show a port.
Chasing a connection that fails from one network and works from another. That is a profile question, and the rule’s Profile value plus the active profile answer it in two commands.
Enabling PowerShell remoting on a laptop. The public-profile rule and its local-subnet restriction are the difference between remoting that works in the office and remoting that works everywhere.
Cleaning up after a migration. A rule that will not delete is almost always a GPO rule seen through the active store, not a broken rule.
Writing an automated build step. -Enabled $true looks correct and stops the script at parameter binding, which is the good failure: it lands at the top of the build log rather than in a rule nobody checked.
Tips and limitations
- Expect to need a full administrator token to write a rule, although the reference pages state no permission requirement.
S-1-16-12288inwhoami /groupsis the reliable elevation check, not membership of the Administrators group. -DisplayNameis the only mandatory name parameter, and it is the wrong one to build a script on. Always set-Nameas well.- Fourteen of the thirty-nine
New-NetFirewallRuleparameters can only be queried through filter objects, and they point at seven different filter cmdlets. - Querying a rule object for a condition returns zero rows and zero errors. Measured with
$ErrorActionPreferenceset toStop, the count was 0 and the error count was 0 both forWhere-Object LocalPort -eqand for the script-block form. LocalPortis a string array. A rule created with a range or with theAnykeyword opens ports that no string comparison will ever match.-Enabledis an enum, not a Boolean, and the reference page says so outright. Measured against a stand-in enum, a Boolean cannot be cast to an enum at all, so-Enabled $truefails at parameter binding rather than binding to something wrong. UseTrueorFalse.- The documented default for
-ProfileisAny, which includes the Public profile. Name the profiles you want. Remove-NetFirewallRulewith no parameters deletes every static local rule, and its reference page does not warn about it.Enable-NetFirewallRulewith no parameters enables every rule, and its page does.- A rule you can see but cannot delete is usually a GPO rule read through
ActiveStore. CheckPolicyStoreSourcebefore assuming anything is broken. - Rule names in Windows are version-dependent. The
Enable-PSRemotingpage says so itself, and gives two different names for the same public-profile fix. - The NetSecurity reference pages carry the same 12/27/2016 date stamp under the Windows Server 2019, 2022 and 2025 monikers, and they differ in very little:
-RemoteDynamicKeywordAddressesappears onNew-NetFirewallRulefrom 2022 onwards, and-GPOSessionis documented onGet-NetFirewallRuleunder 2019 but not under 2022 or 2025. - Opening a port is not the same as something listening on it. Confirm with Test-NetConnection from the client side, not with the rule.
Official documentation
- New-NetFirewallRule: NetSecurity | Microsoft Learn
- Get-NetFirewallRule: NetSecurity | Microsoft Learn
- Set-NetFirewallRule: NetSecurity | Microsoft Learn
- Remove-NetFirewallRule: NetSecurity | Microsoft Learn
- Enable-NetFirewallRule: NetSecurity | Microsoft Learn
- Get-NetFirewallPortFilter: NetSecurity | Microsoft Learn
- Get-NetFirewallAddressFilter: NetSecurity | Microsoft Learn
- Get-NetFirewallApplicationFilter: NetSecurity | Microsoft Learn
- Get-NetFirewallProfile: NetSecurity | Microsoft Learn
- Enable-PSRemoting: Microsoft.PowerShell.Core | Microsoft Learn
- netsh advfirewall: Windows Commands | Microsoft Learn
- Network shell (netsh): Windows Commands | Microsoft Learn
Related tools
- Port Checker: a rule that allows 9443 and a service that answers on 9443 are two different things, and this is the outside-in half of the check.
Related guides
- The netsh command in Windows: the CMD side of firewall rules, plus the contexts that have nothing to do with the firewall.
- Enter-PSSession and WinRM, where -ComputerName is not remoting: where the WINRM rules come from and which ports each transport uses.
- Checking open ports on Windows with built-in tools: finding out what is listening before you decide which rule to write.
- Test-NetConnection for port and path testing: the client-side confirmation that a new rule actually changed something.
- Get-Service and Set-Service in practice: the same silent-empty-result defect, with a status enum instead of a port.
- Nmap on Windows without installing Linux: a second opinion on which ports are reachable from another machine.
Five cheat sheets, one PDF
Subnet masks, PowerShell, Linux commands, HTTP status codes and the ESXi command line - one page each, free to keep. Leave an address and it arrives in a minute.