Driving Windows from Ansible without leaking credentials
The question people argue about is SSH versus WinRM and it is the wrong question, because both are now legitimate: Win32-OpenSSH is a first-class Ansible transport and WinRM is the native one. The question that actually decides your exposure is narrower and sharper: when a play authenticates to a domain-joined host, does a reusable credential cross the wire and can the target then act as you against the rest of the domain. Get the authentication protocol and the delegation model right and either transport is fine. Get them wrong and WinRM will hand an attacker your domain the same way a bad SSH setup would.
The real axis: the authentication protocol
WinRM is a transport that carries one of several auth protocols and they are not interchangeable from a security standpoint.
Basic sends the password, base64-encoded, which is cleartext in every sense that matters, so it is only ever acceptable wrapped in HTTPS and even then it is the wrong default. NTLM does not send the password but answers a challenge, which is replayable and relayable and it is what you fall back to precisely when you have not set up anything better. Kerberos is the one to aim for: the target is handed a ticket, no reusable secret transits and it slots into the AD you already run. CredSSP exists to solve the double-hop problem and does it by delegating the full credential to the target, which means a compromised target now holds a credential it can replay everywhere, so it is the option you reach for last and scope tightly when you must.
The hierarchy is therefore simple: Kerberos by default, NTLM only as a grudging fallback, Basic and CredSSP avoided unless a specific need forces them and the blast radius is understood.
HTTPS, not HTTP
The WinRM HTTP listener on 5985 and the HTTPS listener on 5986 are not two flavours of the same thing. Over HTTP, whether the payload is protected depends entirely on message-level encryption, which Kerberos and NTLM provide but Basic does not, so an HTTP listener with Basic is a credential on the wire. The clean posture is to run HTTPS on 5986 with a real certificate and delete the HTTP listener outright, so the question of message encryption never has to be answered under pressure:
winrm delete winrm/config/listener?Address=*+Transport=HTTP
winrm set winrm/config/service @{AllowUnencrypted="false"}
winrm set winrm/config/service/auth @{Basic="false"; Kerberos="true"; CredSSP="false"}
The controller side
Kerberos from a Linux controller needs a working krb5 client pointed at the domain KDC and the Python bindings:
apt-get install krb5-user
pip install "pywinrm[kerberos]"
# /etc/krb5.conf
[realms]
COMPANY.COM = {
kdc = dc1.company.com
admin_server = dc1.company.com
}
# group_vars/windows.yml
ansible_connection: winrm
ansible_winrm_transport: kerberos
ansible_winrm_scheme: https
ansible_port: 5986
ansible_winrm_server_cert_validation: validate
server_cert_validation: validate is not optional on a hardened setup. The convenience value ignore turns off the one check that makes HTTPS mean anything, so an internal CA whose root the controller trusts is the small amount of PKI this costs and it is worth paying.
The delegation trap
The double-hop problem is real: a play that authenticates to host A and then reaches from A to a file share on host B finds that A has no credential to present, because Kerberos handed it a ticket for A and nothing more. The tempting fix is CredSSP or unconstrained delegation and both are how domains fall. Delegating the full credential to A means that whoever owns A, now or later, owns everything A was trusted to reach.
The defensible answers, in order of preference: avoid the second hop by running the task on the host that owns the resource, use Kerberos constrained delegation scoped to the specific service when a hop is unavoidable and treat CredSSP as the option of last resort, enabled per-play rather than estate-wide and never left on. A transport note that says “just enable CredSSP, it fixes the double-hop” is describing how to lose the domain.
Windows is not the rest of the fleet
For the rest of the fleet, the Ansible reflex is simple: Ansible connects to POSIX managed nodes over OpenSSH by default, so Linux, BSD and even macOS are driven with the same key, the same inventory and the same fleet-management reflex. That reflex is what makes SSH tempting for Windows: a private key instead of certificates, faster file transfers and a simpler setup outside a domain, all officially supported by Ansible only since 2.18, on the OpenSSH shipped with Windows and nothing else (the upstream Win32-OpenSSH package is unsupported), version 7.9.0.0 minimum, meaning Server 2022 and later.
Where the mirage stops is that the Ansible task does not land in an interactive session but in a network logon: the shell runs under the sshd service session, no desktop, no interactive window station. Programs that demand an interactive session do not run, APIs reserved for interactive logons stay closed, the DPAPI store is typically unreachable and the user profile’s network drive mappings do not exist in the session. The documented fix is become: it flips the network logon into an interactive one and, with explicit credentials, makes network resources and the DPAPI store reachable.
And the double hop of the previous section comes back with an extra tile under SSH: a key delegates no credential, so the session cannot reach a network resource on your behalf. Three ways out, all less clean than the WinRM Kerberos of the section above: become with the credentials of the account that must see the resource, GSSAPI with a forwardable ticket and GSSAPIDelegateCredentials=yes, or the password over sshpass, which delegates without constraint the way CredSSP would, with the same reservations plus the fact that a reusable secret crosses the wire. The SSH plugin also cannot obtain a Kerberos TGT from a username and password: the kinit is manual and a short ticket expires mid-playbook. A subtlety that bites fast: an administrator account’s public key does not go in the profile’s .ssh but in C:\ProgramData\ssh\administrators_authorized_keys, set by the Match Group administrators block of sshd_config.
That is where the fleet decision lands: pure config and packaging runs very well over key-based SSH; anything needing a delegated identity, a DPAPI store or a real interactive session ends up necessarily on WinRM, or on become switching context at every task. In a domain-joined fleet, where the Kerberos posture is already standing for WinRM, that second group is not the exception, it is the majority.
Scope the listener and watch it
The WinRM endpoint should answer only the controller, which is a firewall rule, not a hope:
New-NetFirewallRule -DisplayName "WinRM-HTTPS-Ansible" -Direction Inbound `
-LocalPort 5986 -Protocol TCP -Action Allow -RemoteAddress 10.0.1.0/24
And because WinRM logs to the Windows event system, the operational channel is where a failing listener or a rejected auth shows up first:
Get-WinEvent -LogName "Microsoft-Windows-WinRM/Operational" |
Where-Object LevelDisplayName -eq Error
Where it lands
Either transport works. The security lives below that choice: Kerberos so no reusable secret crosses the wire, HTTPS on 5986 with certificate validation on so the channel means something, Basic and the HTTP listener gone and CredSSP or unconstrained delegation kept off until a specific need justifies the blast radius, scoped when it cannot be avoided. WinRM is not more secure than SSH by nature; it is more secure when it is Kerberos over validated TLS and a liability when it is Basic over HTTP. The configuration is the whole story, not the acronym.