SSH into Windows

· · Updated  · 8 min read

Windows is the last host in most estates with its own remote-access story. OpenSSH removes the exception — one transport, one key model, one audit trail — and key distribution, not the transport, is the actual project.

Windows is usually the last host in an estate that still has its own remote-access story: its own protocol, its own credential path, its own exception carved into every runbook. It does not need to be. OpenSSH has shipped in the box since Windows 10 build 1809 and Windows Server 2019, and turning the server side on collapses Windows into the same transport, the same key material and the same audit trail as everything else you run.

I first wrote this up in March 2020 because the documentation was scattered across a dozen pages and I kept rebuilding the sequence from memory. Six years on the documentation is better and the commands have mostly held. Two things changed, one claim in the original needs a date attached to it, and the hard part turns out not to be the setup at all.

Why not just use RDP or WinRM

RDP is an interactive session for a human being. WinRM is a management protocol with its own listener, its own certificate story, its own firewall exception — and it is the one thing your Unix-side tooling does not speak without a translation layer.

With sshd running, ssh host 'command' behaves the same whether the host is AIX, Ubuntu or Server 2022. Your bastion policy covers Windows without a second design. You can forward 3389 down the tunnel and stop exposing RDP to the network at all, which is the one concrete security win in the list.

The honest counterpoint: this is not a security upgrade in itself. You have swapped one listener for another, and an SSH daemon with password authentication left on and a weak local administrator is worse than well-managed WinRM. The gain is uniformity — one thing to harden, patch, log and reason about, instead of two.

The setup

All of this from an elevated PowerShell prompt.

Check what is present, then install the components you need:

Get-WindowsCapability -Online | Where-Object Name -like 'OpenSSH*'

Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0

On Windows Server 2025 skip that step — OpenSSH is installed by default.

Start the service and confirm the firewall rule:

Start-Service sshd
Set-Service -Name sshd -StartupType 'Automatic'

if (-not (Get-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -ErrorAction SilentlyContinue)) {
  New-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' `
    -DisplayName 'OpenSSH Server (sshd)' `
    -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22
}

The conditional is load-bearing. The 2020 version listed the rules with a wildcard, asked you to eyeball the output, and offered an unconditional fallback that created the rule as -Name sshd. Installation creates it as OpenSSH-Server-In-TCP, so that fallback leaves two differently-named rules doing the same job — harmless until somebody audits the firewall and cannot tell which one is live. Gate it on the exact name.

The administrators key file is the step that costs people an afternoon

If the account you are connecting as belongs to the local Administrators group, sshd does not read %userprofile%\.ssh\authorized_keys at all. It reads %programdata%\ssh\administrators_authorized_keys instead. Your key can be in exactly the right place for a Linux habit and the wrong place for this host, and the failure presents as a rejected key rather than a file nobody looked at.

That file also has to satisfy a permissions check: entries for NT Authority\SYSTEM and BUILTIN\Administrators only, with SYSTEM granted full control. Inherited entries will fail it, which is what /inheritance:r strips.

On a host that has never had an administrator key deployed, the file does not exist — sshd does not create it, and the installer does not either. So write the key first and set the permissions second, in that order:

$keys = "$env:ProgramData\ssh\administrators_authorized_keys"

# $pubkey is the single line from your client's .ssh\id_ed25519.pub
Add-Content -Force -Path $keys -Value $pubkey

icacls.exe $keys /inheritance:r /grant '*S-1-5-32-544:F' /grant 'SYSTEM:F'

*S-1-5-32-544 is the built-in Administrators group addressed by SID rather than by name, and the asterisk is what tells icacls the string is a SID and not an account. On a non-English installation the literal Administrators does not resolve, the grant fails, and the symptom is a rejected key on a file that looks right. The SID is the same on every installation; the display name is not.

No service restart belongs here. sshd reads the key file during authentication, and the restart requirement in the documentation applies to sshd_config, not to this.

Set the default shell

Absent from the 2020 post, and it is the first thing most people hit after a login that otherwise worked. The default shell for an inbound session is cmd.exe:

New-ItemProperty -Path 'HKLM:\SOFTWARE\OpenSSH' -Name DefaultShell `
  -Value 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' `
  -PropertyType String -Force

This takes effect on the next connection; the service does not need restarting.

What changed since 2020

Server 2025 ships it. OpenSSH is installed by default and the service is togglable from Server Manager, under Local Server → Remote SSH Access. There is also an OpenSSH Users group for restricting who may connect. The install step above is now only for Windows 10 and 11 and Server 2019 through 2022.

The Ansible claim needs a date on it. The original post said, in passing, that enabling SSH lets you use Ansible to manage the machine. The SSH connection plugin had worked against Windows since Ansible 2.8, but official support arrived only in 2.18, and it requires OpenSSH 7.9.0.0 or newer. In practice that means Server 2022 and later, because Server 2019 ships 7.7.2.1. You also need ansible_connection: ssh and ansible_shell_type: powershell, matching whatever you set DefaultShell to. In 2020 that sentence described something that worked if you were lucky; it now describes something supported, and those are not the same claim.

ssh-keygen defaults to Ed25519 when you do not pass -t. Worth knowing before you generate a fleet of keys on the assumption it is still RSA.

And one line I would now cut rather than correct: the 2020 post started ssh-agent on the server in the same breath as sshd. The agent holds client private keys. On a machine that is only ever an SSH target it does nothing for you — start it where you originate connections from.

Where it still isn’t Linux

This is the part that decides whether “one transport everywhere” survives contact with your estate, and none of it is in the setup instructions.

You cannot pull keys from the directory. Windows OpenSSH does not support the AuthorizedKeysCommand or AuthorizedKeysCommandUser directives. The pattern most Linux fleets use for central key distribution — have sshd ask a script, have the script ask the directory — has no equivalent here. Keys land on each host as files, which means configuration management has to put them there and, more importantly, take them away. Key distribution, not the transport, is what makes this a project rather than an afternoon.

Microsoft Entra ID accounts cannot use key-based authentication. Local accounts and Active Directory accounts can. If your identity story has already moved to Entra, SSH keys are not where it lands, and discovering that after you have rolled out sshd everywhere is an unpleasant way to learn it.

A key-based session cannot authenticate outbound as you. This is by design: a session opened with a key has no associated user credentials, so it cannot reach a file share or a second host as that user. It is the classic double-hop, and it is behind almost every “my script works interactively and fails over SSH” report I have seen. Where you genuinely need delegation, that is GSSAPI or a password — not a key.

For completeness, because the two are easy to conflate: the AuthenticationMethods directive on Windows OpenSSH accepts password and publickey, and nothing else. GSSAPI sits outside that directive and is configured by GSSAPIAuthentication, which exists only from Server 2022, Windows 11 and the May 2021 update to Windows 10, and defaults to no. So delegation is available on a subset of your estate, off until you turn it on, and absent from anything older.

What I would actually do

Turn it on everywhere, keys only, and disable password authentication in sshd_config once keys are proven on a representative host — then restart sshd, because the daemon reads that file at service start and nowhere else, so an un-restarted host is still accepting passwords while the config says it is not. Put the Windows hosts behind the same bastion as everything else, forward RDP down the tunnel rather than exposing 3389, and treat administrators_authorized_keys as configuration-managed state rather than a file an engineer edits during an incident.

Then budget for the directory-integration gap, because that is the work. The transport takes an afternoon. Deciding how a key gets onto four hundred Windows hosts, and off them again the day someone leaves, is the actual project — and it is the same project you already solved on the Unix side, which is rather the point of doing this at all.

Sources