Fix Seven Windows Server 2025 OpenSSH STIG Findings in sshd_config
All eleven WN25-SH OpenSSH checks run before and after, and the seven that fail on a fresh Server 2025 image end up printing what their check text expects.

On this page
TL;DR
Add seven directives to sshd_config above the Match block, run sshd -t, then restart sshd:
Banner C:\ProgramData\ssh\Banner.txt
PubkeyAuthentication yes
PermitEmptyPasswords no
RekeyLimit 1G 1h
ClientAliveCountMax 1
ClientAliveInterval 600
GSSAPIAuthentication noThe script in “Add the seven directives to sshd_config” does this safely: it copies the stock file first and refuses to run if the file already sets any of these directives or has an Include line, because sshd uses the first value it reads. If you edit by hand, run & C:\Windows\System32\OpenSSH\sshd.exe -t, then Restart-Service sshd.
That configures WN25-SH-000020, -000030, -000040, -000050, -000060, -000070 and -000110 as written in the Windows Server 2025 STIG, V1R4. On a fresh Datacenter image, -000010, -000090 and -000100 needed no change, and -000080 depends on how you read inherited permissions. Write all seven directives even where the sshd default already matches: each check reads the file, so a missing line is a finding. The file named by Banner must hold the notice text the rule names. This shows each rule configured as written; it’s not a claim that the server is compliant. Do not disable RDP until key login works from your client and you have tested the way back.
The Windows Server 2025 STIG has eleven OpenSSH rules, the WN25-SH group. The first, WN25-SH-000010, isn’t applicable when OpenSSH isn’t installed, but once it’s installed, a stopped sshd is a finding. I ran all eleven checks in that group, from release V1R4 as listed on Cyber Trackr, against a fresh Datacenter image before changing anything. Seven failed as written.
This guide assumes OpenSSH is installed and that you can already sign in with a key. If sshd isn’t running, enable SSH on Windows Server 2025 first; if you have no administrator key in place, configure SSH key authentication for an administrator. Run the commands in an elevated PowerShell session on the server, or through your provider’s run-command feature (Azure’s RunPowerShellScript runs as SYSTEM).
Confirm the STIG release
Rule IDs and check text belong to a release. The text here is from Windows Server 2025 STIG, Version 1, Release 4, benchmark date September 1, 2026, as listed on Cyber Trackr (opens in a new tab). I didn’t find that release on DISA’s own site when I searched, so confirm it in DISA’s STIG library (opens in a new tab) before you cite it in an audit.
V1R4 has 291 requirements. Eleven carry “SSH” in the title, V-285313 to V-285323, which are WN25-SH-000010 to WN25-SH-000110 (the table in the next section pairs them). If your release numbers them differently, use your release’s check text, not the lines here.
Run every check before you change anything
Each command in this block is the rule’s own check text; the first two have a formatting pipe added so the output is compact. Run them first so you have a baseline.
Get-WindowsCapability -Online | Where-Object Name -like 'OpenSSH*' | Format-List Name,State
Get-Service sshd | Format-Table Status,Name,DisplayName -AutoSize
Get-Content "$env:ProgramData\ssh\sshd_config" | Select-String -Pattern '^\s*Banner'
Get-Content "$env:ProgramData\ssh\sshd_config" | Select-String -Pattern '^\s*PubkeyAuthentication'
Get-Content "$env:ProgramData\ssh\sshd_config" | Select-String -Pattern '^\s*PermitEmptyPasswords'
Get-Content "$env:ProgramData\ssh\sshd_config" | Select-String -Pattern '^\s*RekeyLimit'
Get-Content "$env:ProgramData\ssh\sshd_config" | Select-String -Pattern '^\s*ClientAliveCountMax'
Get-Content "$env:ProgramData\ssh\sshd_config" | Select-String -Pattern '^\s*ClientAliveInterval'
icacls $env:ProgramData/ssh/sshd_config
Get-ChildItem -Path "C:\ProgramData\ssh" -Filter "*_key" -File | ForEach-Object { icacls.exe $_.FullName }
Get-ChildItem -Path "C:\ProgramData\ssh" -Filter "*key.pub" -File | ForEach-Object { icacls.exe $_.FullName }
Get-Content "$env:ProgramData\ssh\sshd_config" | Select-String -Pattern '^\s*GSSAPIAuthentication'To read the effective values, which come from sshd and not from the file, run sshd -T. The table cites it:
& C:\Windows\System32\OpenSSH\sshd.exe -T |
Select-String -Pattern '^(banner|pubkeyauthentication|permitemptypasswords|rekeylimit|clientalivecountmax|clientaliveinterval|gssapiauthentication) 'On the fresh image I tested, the first two commands (WN25-SH-000010) printed both capabilities Installed and sshd Running. The seven Select-String commands printed nothing. The three permission commands printed the entries shown in the table.
| Rule | V-number | What the check printed | Against the check text |
|---|---|---|---|
| WN25-SH-000010 | V-285313 | Both capabilities Installed, sshd Running | Not a finding |
| WN25-SH-000020 | V-285314 | Nothing. sshd -T shows banner none | Finding |
| WN25-SH-000030 | V-285315 | Nothing. sshd -T shows pubkeyauthentication yes | Finding as written; the line is missing, though the default is yes |
| WN25-SH-000040 | V-285316 | Nothing. sshd -T shows permitemptypasswords no | Finding as written; the line is missing, though the default is no |
| WN25-SH-000050 | V-285317 | Nothing. sshd -T shows rekeylimit 0 0 | Finding |
| WN25-SH-000060 | V-285318 | Nothing. sshd -T shows clientalivecountmax 3 | Finding |
| WN25-SH-000070 | V-285319 | Nothing. sshd -T shows clientaliveinterval 0 | Finding |
| WN25-SH-000080 | V-285320 | SYSTEM full, Administrators full, Authenticated Users read and run, all inherited | See the permission rules later |
| WN25-SH-000090 | V-285321 | Three private keys, Administrators and SYSTEM full only | Matches the example |
| WN25-SH-000100 | V-285322 | Three public keys, Administrators and SYSTEM full only | Matches the example |
| WN25-SH-000110 | V-285323 | Nothing. sshd -T shows gssapiauthentication no | Finding as written; the check text says no output is a finding |
Seven checks fail as written: -000020, -000030, -000040, -000050, -000060, -000070 and -000110. Three of them (-000030, -000040 and -000110) fail only because the check searches the file for a line that restates what sshd does by default. The server already behaves as the rule intends; the file doesn’t say so. The other four are real gaps: no banner is shown, there is no time-based rekey, ClientAliveInterval 0 turns keepalives off entirely, and ClientAliveCountMax is 3 where the rule requires 1.
On this image sshd was already running with start type Automatic, and the OpenSSH-Server-In-TCP firewall rule’s profile was Any. When I tested build 26100.33438 for Enable SSH on Windows Server 2025, the service was stopped and the rule covered Private only. Check your own image; the steps here don’t depend on which you have.
Add the seven directives to sshd_config
This changes sshd_config and creates Banner.txt if it doesn’t exist. The script stops, changing nothing, if:
- there is no
Matchline; sshd_config.stockalready exists;- the file already sets any of the seven directives, or has an
Includeline, since an included file could set one too.sshduses the first value it reads for a directive, so a line added after an existingGSSAPIAuthentication yeswould passsshd -tand change nothing; edit such a line by hand instead.
Otherwise the script copies the stock file, writes a placeholder Banner.txt if there is none (an existing banner is left as it is), inserts the seven directives above the first Match line and checks the result with sshd -t. It restarts the service only if the check passes. The guards and the restore-on-failure path were added after the run I measured, and I did not exercise them.
Every directive must sit above the Match Group administrators block. A line after it belongs to that block and applies only to administrators. The STIG checks still pass in that case, because they only search the file, so check where the lines landed with the sshd -T command from the baseline section.
$cfg = "$env:ProgramData\ssh\sshd_config"
if (Test-Path "$cfg.stock") { throw "$cfg.stock already exists; the script already ran. Restore with: Copy-Item '$cfg.stock' '$cfg' -Force, then delete the .stock file to run again." }
$lines = Get-Content $cfg
$i = [Array]::FindIndex($lines, [Predicate[string]]{ param($l) $l -match '^\s*Match\s' })
if ($i -lt 0) { throw 'No Match line in sshd_config; place the directives by hand.' }
$set = $lines | Where-Object { $_ -match '^\s*(Banner|PubkeyAuthentication|PermitEmptyPasswords|RekeyLimit|ClientAliveCountMax|ClientAliveInterval|GSSAPIAuthentication|Include)(\s|=)' }
if ($set) { throw "sshd_config already sets or includes: $($set -join '; '). sshd uses the first value it reads, so edit these lines by hand." }
Copy-Item $cfg "$cfg.stock"
$banner = "$env:ProgramData\ssh\Banner.txt"
if (-not (Test-Path $banner)) { Set-Content -Path $banner -Encoding ascii -Value 'Replace with the required notice and consent banner text.' }
$add = @(
'Banner C:\ProgramData\ssh\Banner.txt'
'PubkeyAuthentication yes'
'PermitEmptyPasswords no'
'RekeyLimit 1G 1h'
'ClientAliveCountMax 1'
'ClientAliveInterval 600'
'GSSAPIAuthentication no'
)
$before = if ($i -gt 0) { $lines[0..($i-1)] } else { @() }
$new = @($before) + $add + $lines[$i..($lines.Count-1)]
Set-Content -Path $cfg -Value $new -Encoding ascii
& C:\Windows\System32\OpenSSH\sshd.exe -t
if ($LASTEXITCODE -ne 0) { Copy-Item "$cfg.stock" $cfg -Force; Remove-Item "$cfg.stock"; throw "sshd -t failed ($LASTEXITCODE); sshd_config restored, sshd not restarted." }
Restart-Service sshd
Get-Service sshd | Format-Table Status,Name -AutoSizesshd -t prints nothing and exits with code 0 when the file is valid. If it prints a line number, the script puts the stock file back, deletes sshd_config.stock and stops before the restart. Correct what the line names, such as a bad Banner path, and run the script again. If you edit by hand and sshd -t fails, restore your own copy of the file before you restart.
Replace the placeholder in Banner.txt with the notice text your organization requires; WN25-SH-000020 names the Standard Mandatory DoW Notice and Consent Banner.
Rewriting the file with Set-Content kept its inherited ACL, so the change doesn’t touch WN25-SH-000080.
Read the permission rules, then leave them alone
WN25-SH-000080, -000090 and -000100 give no fix command. Their fix text says to maintain the permissions as they are, so there is nothing to run.
- -000090 and -000100: each host key file, private and public, should show
BUILTIN\Administrators:(F)andNT AUTHORITY\SYSTEM:(F)and nothing else. They did on the image I tested. - -000080:
sshd_configshould showNT AUTHORITY\SYSTEM:(F),BUILTIN\Administrators:(F)andNT AUTHORITY\Authenticated Users:(RX). On the image I tested it had those principals and rights, and every entry was marked(I)for inherited, which the rule’s example doesn’t show.
C:\ProgramData/ssh/sshd_config NT AUTHORITY\SYSTEM:(I)(F)
BUILTIN\Administrators:(I)(F)
NT AUTHORITY\Authenticated Users:(I)(RX)The rule text doesn’t say whether inherited entries count as “the default permission”. I left the file as found. An assessor who reads the example literally will raise the (I) markers, so ask how they read it.
Run every check again
Run the block of check commands from the baseline section again. Each of the seven that printed nothing now prints the line its check text expects:
Banner C:\ProgramData\ssh\Banner.txt
PubkeyAuthentication yes
PermitEmptyPasswords no
RekeyLimit 1G 1h
ClientAliveCountMax 1
ClientAliveInterval 600
GSSAPIAuthentication noThe permission checks printed the same as before. Then run the sshd -T command from the baseline section and compare it with these values. If any value is still the default, the directive is after the Match line.
clientaliveinterval 600
clientalivecountmax 1
pubkeyauthentication yes
gssapiauthentication no
permitemptypasswords no
banner C:\ProgramData\ssh\Banner.txt
rekeylimit 1073741824 3600From a client, the banner appears before the command runs. A password attempt with no password supplied gets the banner first too:
$ ssh -i ~/.ssh/<key> -o IdentitiesOnly=yes <admin>@<ip> 'whoami'Output
<banner text>
<server>\<admin>$ ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password -o BatchMode=yes <admin>@<ip> 'whoami'Output
<banner text>
<admin>@<ip>: Permission denied (publickey,password,keyboard-interactive).I read the effective values of ClientAliveInterval 600, ClientAliveCountMax 1 and RekeyLimit 1G 1h, but I never exercised them: I didn’t leave a session idle to see it end and I didn’t push a gigabyte through one.
Settings the WN25-SH rules don’t cover
A server that passes these checks still has password login on and TCP forwarding on. On the tested image sshd -T reported both after configuration. To check yours:
& C:\Windows\System32\OpenSSH\sshd.exe -T | Select-String -Pattern '^(passwordauthentication|allowtcpforwarding) 'The output:
passwordauthentication yes
allowtcpforwarding yesI downloaded all 291 V1R4 requirements and searched them for AllowTcpForwarding. No file matched. Forwarding matched one, WN25-DC-000320, an LDAP rule that uses the word in its prose. As a check that the search could match, searching the same files for ssh matched all eleven SH rules. No WN25-SH rule names PasswordAuthentication either. If you want password login off, choose who can sign in covers PasswordAuthentication and DenyGroups on this file; forwarding is your decision. The checks above won’t notice either way.
Regenerate host keys on a shared image
No rule catches this one: -000090 and -000100 check the permissions on the host keys, not whether the keys are unique, so they pass on shared keys.
What I observed: two VMs created from one Microsoft-published marketplace image version (publisher MicrosoftWindowsServer, offer WindowsServer, SKU 2025-datacenter-g2, version 26100.33650.261013, region westus2) presented identical host key fingerprints. The key comment named the image builder’s host, not the VM. The keys were created when the image was built, a week before either VM existed. Anyone who deploys the same image holds the same host private keys, so a client’s host key check can’t tell the servers apart. I tested only that image version in that region; I didn’t check other versions or regions.
Compare your fingerprints against another VM from the same image, or look for a key comment that isn’t your host name:
Get-ChildItem C:\ProgramData\ssh\*_key.pub | ForEach-Object { & C:\Windows\System32\OpenSSH\ssh-keygen.exe -lf $_.FullName }256 SHA256:Zb2d+RmPB/XK/T+2QXE9nNnLrmamzGN6t6QXacknueM system@<build-host> (ECDSA)
256 SHA256:9sUv3MY9WEp66YaWa9cQubKDPuLKiv/IPvBtKWnXGIk system@<build-host> (ED25519)
3072 SHA256:UIJWMbbasYsbFwVO0nvy7snMo0ZYk8pxtCTBy4e5l3E system@<build-host> (RSA)To regenerate, this deletes the server’s host keys, so every client that stored the old key will see a changed key on the next connection. Run it on the server, ideally from the provider’s run-command rather than over SSH, since it stops sshd:
Stop the service and delete the keys:
PowerShellStop-Service sshd Remove-Item C:\ProgramData\ssh\ssh_host_*Start the service, which generates three new keys:
PowerShellStart-Service sshd Get-Service sshd | Format-Table Name,Status -AutoSizePrint the new fingerprints. The key comment now names this VM, and the fingerprints differ from another VM of the same image:
PowerShellGet-ChildItem C:\ProgramData\ssh\*_key.pub | ForEach-Object { & C:\Windows\System32\OpenSSH\ssh-keygen.exe -lf $_.FullName }Run the WN25-SH-000090 and -000100 checks again:
PowerShellGet-ChildItem -Path "C:\ProgramData\ssh" -Filter "*_key" -File | ForEach-Object { icacls.exe $_.FullName } Get-ChildItem -Path "C:\ProgramData\ssh" -Filter "*key.pub" -File | ForEach-Object { icacls.exe $_.FullName }
On the tested VM the new keys had Administrators and SYSTEM full and nothing else, which is what both rules expect.
Disable RDP
RDP is not a WN25-SH rule, and nothing here cites a STIG for it. Turning it off is a choice to leave SSH as the only way in, so do it last and only with a tested way back. If you have no out-of-band console or run-command, do not disable RDP.
Write the way back first. This script turns RDP back on and prints the listener. Save it where you can send it without RDP or SSH, such as your provider’s run-command feature or console:
PowerShellSet-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name fDenyTSConnections -Value 0 Enable-NetFirewallRule -DisplayGroup 'Remote Desktop' Get-ItemProperty 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name fDenyTSConnections | Format-List fDenyTSConnections Get-NetTCPConnection -LocalPort 3389 -State Listen -ErrorAction SilentlyContinue | Format-Table LocalAddress,LocalPort,State -AutoSizeTest the way back now: send the step 1 script through your provider’s run-command and confirm it prints
fDenyTSConnections0 and a listener on 3389.Confirm key login works from your client. If it doesn’t, stop here. If you regenerated host keys, remove the stale entry on the client first with
ssh-keygen -R <ip>, then check the new fingerprint against step 3 of the regeneration section before you trust it.Command$ ssh -i ~/.ssh/<key> -o IdentitiesOnly=yes <admin>@<ip> 'whoami'Confirm RDP is reachable now, so you can tell the change from the baseline:
Command$ nc -z -v -G 8 <ip> 3389Output
Connection to <ip> port 3389 [tcp/ms-wbt-server] succeeded!Disable RDP over SSH. This sets
fDenyTSConnectionsto 1 and disables the Remote Desktop firewall group. Put these lines in a file,rdp-off.ps1, on the client:PowerShellSet-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name fDenyTSConnections -Value 1 Disable-NetFirewallRule -DisplayGroup 'Remote Desktop' Get-ItemProperty 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name fDenyTSConnections | Select-Object fDenyTSConnections Get-NetFirewallRule -DisplayGroup 'Remote Desktop' | Format-Table Name,Enabled -AutoSizeThe login shell on the server is
cmd.exe, so send the script as an encoded command:Command$ ENC=$(iconv -f UTF-8 -t UTF-16LE rdp-off.ps1 | base64) $ ssh -i ~/.ssh/<key> -o IdentitiesOnly=yes <admin>@<ip> "powershell -NoProfile -EncodedCommand $ENC"Expect
fDenyTSConnectionsto print 1 and three firewall rules,RemoteDesktop-Shadow-In-TCP,RemoteDesktop-UserMode-In-TCPandRemoteDesktop-UserMode-In-UDP, withEnabledFalse.Confirm from the client that 3389 no longer answers and SSH still works:
Command$ nc -z -v -G 8 <ip> 3389Output
nc: connectx to <ip> port 3389 (tcp) failed: Operation timed outCommand$ ssh -i ~/.ssh/<key> -o IdentitiesOnly=yes <admin>@<ip> 'whoami'
The probe times out; it is not refused. The firewall drops the packet, so the client is never told no. A timeout is the result you want.
I ran the way back after disabling RDP, not before: I sent the script from step 1 through the provider’s run-command, and 3389 listened again. Step 2 puts that test ahead of the change. A probe that sends one RDP connection request got an answer with CredSSP negotiated. I didn’t open a full desktop session.
Common failures
Check prints nothing, but sshd -T shows the right value
The check reads the file, not the running service. sshd -T shows the effective value, which includes defaults. A missing directive is a finding as written, whatever the default. Add it as in the sshd_config section.
#< CLIXML block appears after an encoded command over SSH
-EncodedCommand over SSH writes a PowerShell progress record to stderr, starting with #< CLIXML. It isn’t an error. The output you asked for is printed apart from it.
RDP probe times out
The timeout is the success case after step 5. A timeout before you disable RDP means the firewall or the network security rule in front of the server is blocking 3389. The baseline in step 4 will have failed too.
Tested with
- OS
- Windows Server 2025 Datacenter (build 26100.33650), vanilla image, not Azure Edition
- OpenSSH
- OpenSSH_for_Windows_9.5p2, LibreSSL 3.8.2
- STIG
- Windows Server 2025 V1R4 (Cyber Trackr)
- Client
- macOS 27.0.1, OpenSSH_10.3p1
- Date
- October 2026