This one started with VS Code. I do a lot of work over Remote-SSH, and every new host I pointed it at meant typing my password again. Not once, either. The extension opens more than one SSH connection when it attaches to a host, so password authentication means answering the same prompt several times before the editor is even usable, and again on every reconnect.
VS Code's own documentation has a fix for that, and it is the one thing I could not use. The suggested workaround is ControlMaster, multiplexing the extension's connections over a single one, and it is supported on macOS and Linux clients only. On Windows the documented answer is simply to use key-based authentication instead.
Fine. Except putting a key on a host from a Windows workstation is where the whole thing falls over. On Linux or macOS it is one command, ssh-copy-id user@host. On Windows it is not a command at all.
Microsoft's OpenSSH port ships ssh, ssh-keygen, ssh-agent, ssh-add, ssh-keyscan, scp and sftp. It does not ship ssh-copy-id. The feature request has been sitting open on the Win32-OpenSSH tracker for years, and in the meantime everybody arrives at the same workaround:
type $env:USERPROFILE\.ssh\id_ed25519.pub | ssh user@host "cat >> .ssh/authorized_keys"
That works, right up until it doesn't. Run it twice and you get the key twice. Run it against a host whose authorized_keys has no trailing newline and you splice your key onto the end of the previous entry, destroying both. Run it against a fresh account with no .ssh directory and it fails outright. Run it after restoring a home directory from backup and SELinux quietly refuses the file even though the permissions look perfect. Run it against a Windows Server box and it falls apart completely, because there is no cat on the far side and an administrator's key does not even belong in the profile directory.
Worst of all, it never tells you whether the key actually works. It tells you the bytes arrived.
I kept thinking somebody should write a proper ssh-copy-id for Windows. Eventually I accepted that somebody was me. The result is Deploy-SshKey.ps1, a single script with no module dependencies that runs on Windows PowerShell 5.1 and PowerShell 7. This post walks through what it does and why each piece is shaped the way it is.
Same muscle memory
The first design goal was that I should not have to learn new spellings. The target is positional and every ssh-copy-id option has an equivalent, most of them under the original short name:
.\Deploy-SshKey.ps1 root@server1 -i D:\Keys\prod_admin -p 2222 -o ConnectTimeout=10
| ssh-copy-id | Deploy-SshKey | Short form |
|---|---|---|
-i FILE |
-KeyPath |
-i, -IdentityFile |
-o OPTION |
-SshOption |
-o, -Option |
-p PORT |
-Port |
-p |
-t PATH |
-RemoteAuthorizedKeysPath |
-TargetPath |
-F FILE |
-SshConfigFile |
-ConfigFile |
-s |
-UseSftp |
-Sftp |
-x |
-TraceRemoteCommand |
-x |
-f |
-ForceInstall |
none |
-n |
-WhatIf |
none |
Four single letters are deliberately left unbound. PowerShell parameter names are not case sensitive, so -f and -F would collide into one parameter and the force/config-file distinction could not survive. -t would silently bind -RemoteAuthorizedKeysPath, meaning -t Windows installs your key to a relative path called Windows. Leaving them unbound produces PowerShell's "parameter name is ambiguous" error listing the real candidates, which is the safe outcome.
One difference is worth memorising: ssh-copy-id -f is -ForceInstall here. This script's -Force deletes the key pair at -KeyPath and generates a new one.
Knowing what it is talking to
An SSH server sends its identification banner before authentication, so the platform can be established with no password and no second login. That is a plain TCP read:
$client = [System.Net.Sockets.TcpClient]::new([System.Net.Sockets.AddressFamily]::InterNetworkV6)
$client.Client.DualMode = $true
The explicit address family is not decoration. The parameterless TcpClient constructor is IPv4 only on .NET Framework, so under Windows PowerShell 5.1 it cannot dial an IPv6 literal at all. It throws, the probe returns nothing, and the host gets silently assumed to be POSIX. .NET Core happens to default to dual mode, which is why the bug only ever shows on 5.1.
Matching is narrow on purpose:
if ($banner -match 'OpenSSH_for_Windows') {
return 'Windows'
}
Cygwin and MSYS builds report a plain OpenSSH banner and genuinely want the POSIX install. Matching a bare Windows anywhere in the banner was too loose, since a POSIX host is free to advertise a product name containing that word.
The install itself
On a POSIX target the script builds a small sh script rather than a one-liner. The idempotent append is the heart of it:
addkey() {
if tr -d "\r" < "$f" | grep -qxF "$1"; then return 0; fi
if [ -s "$f" ] && [ -n "$(tail -c 1 "$f")" ]; then echo >> "$f"; fi
echo "$1" >> "$f"
n=$((n+1))
}
Three things are happening. The file is CR-normalised before comparing, because a key written by an earlier run from a Windows client otherwise never matches and gets appended a second time. The last byte is checked, because appending to a file whose final line has no terminator splices the new key onto the previous entry and ruins both. And n counts, so the script can report how many entries the host actually added rather than guessing.
Around that sit umask 077, directory creation, chmod 700 on ~/.ssh, chmod 600 on the file, and this:
if command -v restorecon >/dev/null 2>&1; then restorecon -F "$d" "$f" >/dev/null 2>&1; fi
SELinux denies sshd access to a correctly permissioned authorized_keys whose label is wrong, which is routine after a home directory is restored or migrated. Real ssh-copy-id relabels for exactly this reason.
The whole body runs through sh regardless of the account's login shell, so a user whose shell is csh, tcsh or fish is handled rather than failing on syntax. When a custom -RemoteAuthorizedKeysPath is given, the chmod 700 is skipped: a site-wide location such as /etc/ssh/authorized_keys would otherwise lock every other account out of its own keys.
The quoting problem
Getting that script to the target intact turned out to be the hardest part. Windows PowerShell 5.1 mangles embedded double quotes when it builds a native command line, so "$f" arrives with its quoting stripped and the far side runs something other than what was written.
The script tests for the capability rather than checking a version:
$mode = Get-Variable -Name 'PSNativeCommandArgumentPassing' -ValueOnly -ErrorAction SilentlyContinue
if (-not $mode) {
return $false
}
return ($mode -ne 'Legacy')
PowerShell only gained correct escaping in 7.2, so 5.1 and 6.0 through 7.1 are all unsafe. But 7.2 and later can be put back into the old behaviour by one line in a profile, and a version test would never notice. Given a safe client, the keys go as positional parameters and the target needs nothing beyond a POSIX shell. Given an unsafe one, the script is base64 encoded and piped through base64 -d | sh, which no client-side quoting can damage.
What the script refuses to do is attempt the direct form on a client that mangles quotes. The body would still parse, but "$@" degrades to word splitting, so a single key arrives as several fragments, each appended as its own bogus entry, while the run reports success. Failing loudly is the only honest outcome.
Windows targets
Windows Server running OpenSSH gets a completely different path: a base64 -EncodedCommand sent to powershell.exe, so nothing in the payload is re-parsed by cmd.exe.
The important detail is which file receives the key. Microsoft's default sshd_config has a Match Group administrators block that reads only %ProgramData%\ssh\administrators_authorized_keys. A key written to an administrator's profile authorized_keys silently does nothing. So the script resolves group membership by SID, matching how sshd itself evaluates that rule, and writes to whichever file will actually be read. If it cannot write under ProgramData, it falls back to the profile file, reports why, and marks the run failed, because for an administrator that file is not going to work.
There is a hard ceiling here: the whole payload has to fit one 8191 character command line. An RSA 4096 key uses roughly 6900 of them, so the script refuses rather than letting cmd.exe truncate silently.
Locking down the local key
Windows OpenSSH refuses a private key with loose permissions. Hardening it takes three icacls calls, and the first is not optional:
$null = & icacls $KeyPath /reset
$null = & icacls $KeyPath /inheritance:r
$null = & icacls $KeyPath /grant:r "*${identitySid}:F"
/grant:r replaces the entry for one identity and leaves every other explicit ACE alone. Since ssh-keygen writes its own SYSTEM and Administrators entries, the obvious two-step version claims to reduce the ACL to the current user and does not. /reset drops explicit entries back to inherited, /inheritance:r removes those, and /grant:r adds the one that should remain.
Note the SID rather than USERDOMAIN\USERNAME. That pair is wrong for Entra-joined and Microsoft-account logins, where the account is AzureAD\user while USERDOMAIN reports the machine name. icacls is also a native command, so it signals failure through its exit code and a try/catch around it never fires.
Verifying the right thing
This is the feature I am most pleased with, because it fixes a failure I have actually been bitten by.
The naive check is "can I get in without a password". That is the wrong question on any host that already trusts a different key. IdentitiesOnly=yes governs what the agent may offer; it does not stop an IdentityFile in your ssh_config from being tried alongside the -i key. ssh stops at the first identity the server accepts, so a host trusting your config key answers yes to a question asked about a key it has never seen, and the key you asked to deploy is skipped entirely.
The only place ssh says which key was accepted is verbose output, so that is what gets parsed:
foreach ($line in $result) {
if ($line -match 'Server accepts key' -and $line -match [regex]::Escape($Fingerprint)) {
return $true
}
}
The fingerprint comes from ssh-keygen -lf on the public half. Both the pre-check and post-check run with BatchMode=yes, so no password can ever make verification pass. That does mean a passphrase-protected key with no agent cannot be probed at all, which is why Status and Verified are separate fields on the result object rather than one flag.
Unattended runs
ssh reads passwords from the console device, never from stdin, which is why piping a password at it does nothing and an automated run against a new host simply stops. OpenSSH's own escape hatch is SSH_ASKPASS: it runs a named program and reads one line of stdout. SSH_ASKPASS_REQUIRE=force makes that apply even when a console exists.
The helper the script writes contains no secret:
[System.IO.File]::WriteAllText(
$helperPath,
"@echo off`r`necho %$variableName%`r`n",
[System.Text.ASCIIEncoding]::new())
It echoes an environment variable whose name is generated per run. That variable is never set on the script's own process. Each ssh session is started through ProcessStartInfo with the value placed in the child's environment block only, so no other child can inherit it and it cannot be read out of the parent. The credential is carried as a SecureString until the moment a child launches. ASCII and CRLF matter because cmd.exe is the interpreter and a BOM would be read as part of the @echo directive.
The residual exposure is stated plainly in the help: the password still lives in ssh's memory, and briefly as a .NET string here. Nothing removes that, because ssh has to receive the password somehow. Anyone who can read it can equally read the private key you are deploying.
The rest of the feature set
-UseAgentKeysinstalls every identity the agent holds, probing each separately so a host already trusting some receives only the remainder. This covers keys whose private half lives on a token and has no file to point at.-UseSftpreaches accounts that cannot execute a command at all: a restricted shell,ForceCommand internal-sftp, or/usr/sbin/nologin. It downloads, merges and rewrites the file, with a re-read before the overwrite and a read-back afterwards.-UpdateSshConfigwrites aHostblock recording user, port and identity file, optionally with a short-ConfigAlias. This is the one that closes the loop on the original problem:~/.ssh/configis the same file Remote-SSH reads its host list from, so a host deployed with this switch shows up in the VS Code picker already pointed at the right key. An existing block is displayed and replaced only after confirmation.-RequireExistingKeyrefuses to generate. Generating on demand is convenient for a first run and dangerous for every run after it, since a mistyped-KeyPathotherwise mints a new identity, deploys it, and reports success.-TraceRemoteCommandruns the remote body underset -xand echoes the trace back, which is the only way to see what the target actually did.- Pipeline support throughout, with a result object per host carrying
Status,Verified,KeysInstalled,RemoteKeyFile,RemoteMessageand a sharedCorrelationId.
Get-ADComputer -Filter { OperatingSystem -like '*Server*' } |
Select-Object @{ Name = 'ComputerName'; Expression = { $_.DNSHostName } } |
.\Deploy-SshKey.ps1 -RequireExistingKey -StrictHostKeyChecking yes |
Where-Object Status -eq 'Failed' |
Export-Csv .\failures.csv -NoTypeInformation
Gotchas worth knowing before the first run
| Symptom | Cause |
|---|---|
| Run appears to hang | Almost always a password prompt you cannot see. -SshOption ProxyJump=... is the usual culprit, because ssh applies -i to the final destination only and the hop falls back to a password |
| Every host in a fleet run fails | A wrong password. OpenSSH 9.8 turned on PerSourcePenalties by default, which penalises your client address for up to 600 seconds per source |
| Key installed, sshd still refuses it | On Windows, usually the wrong authorized_keys file. Re-run with -TraceRemoteCommand |
Verified false but Status Installed |
A passphrase-protected key with no agent. Every probe uses BatchMode, so it cannot be proven, only reported |
StrictHostKeyChecking defaults to accept-new, which trusts a host key on first contact. That is convenient, and it is also the exact moment a man-in-the-middle would capture the password you are about to type. Across an untrusted network, pre-populate known_hosts and pass -StrictHostKeyChecking yes.
Was it worth it
Honestly, the first version took an afternoon and did roughly what the cat >> one-liner did. It solved the VS Code problem that afternoon and I could have stopped there. Everything since has been the accumulated result of it failing in ways I did not anticipate: the duplicate key, the missing newline, the SELinux label, the Windows administrators file, the quoting bug that split a key into fragments while reporting success, the verification that proved the wrong key.
That is the honest argument for building your own version of a tool that already exists elsewhere. Not because writing it is clever, but because every one of those failures is a thing ssh-copy-id learned about years ago, and porting the tool means porting the lessons too. The script is 4300 lines, most of it comment-based help and the reasoning behind decisions that are not obvious from the code, with a Pester 6 suite alongside it that runs entirely offline.
If you work from Windows and administer anything with an SSH daemon on it, grab it from the repo. There is a troubleshooting companion in there too, which is where the symptoms above are worked through properly.