How to Write an SSH Client Script in Go With Examples
Write an SSH client in Go with golang.org/x/crypto/ssh: key or agent authentication, verified host keys through known_hosts, timeouts and running a command.

Shelling out to ssh is fine for most automation, and a native client becomes attractive when a Go tool must run commands on many hosts without depending on a system binary, or needs fine control over connections. The library is maintained alongside the standard library. This post builds the minimum that is also correct about host keys, which is where examples often cut corners. It extends Go command line tools. Targets Go 1.24 and golang.org/x/crypto at a current release.
The client
package main
import (
"fmt"
"log"
"net"
"os"
"path/filepath"
"time"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"golang.org/x/crypto/ssh/knownhosts"
)
func main() {
host, user, command := os.Args[1], os.Args[2], os.Args[3]
hostKeys, err := knownhosts.New(filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"))
if err != nil {
log.Fatal(err)
}
sock, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK"))
if err != nil {
log.Fatal("no ssh agent: ", err)
}
defer sock.Close()
cfg := &ssh.ClientConfig{
User: user,
Auth: []ssh.AuthMethod{ssh.PublicKeysCallback(agent.NewClient(sock).Signers)},
HostKeyCallback: hostKeys,
Timeout: 10 * time.Second,
}
client, err := ssh.Dial("tcp", net.JoinHostPort(host, "22"), cfg)
if err != nil {
log.Fatal(err)
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
log.Fatal(err)
}
defer session.Close()
out, err := session.CombinedOutput(command)
fmt.Print(string(out))
if err != nil {
log.Fatal(err)
}
}go mod init remote && go get golang.org/x/crypto/ssh
go run remote.go web1.example.org deploy 'uptime'The agent supplies the signers, so the program never reads a private key or a passphrase. The host key callback comes from the same known_hosts file the OpenSSH client uses, which means a host you have already trusted works, and an unknown or changed key fails with a clear error. If the host is new, connect once with the normal ssh to record the key, or fetch it with ssh-keyscan and verify the fingerprint, as in keyscan and known hosts.
A key file instead of an agent
pem, err := os.ReadFile(filepath.Join(os.Getenv("HOME"), ".ssh", "id_ed25519"))
if err != nil { log.Fatal(err) }
signer, err := ssh.ParsePrivateKey(pem)
if err != nil { log.Fatal(err) }
auth := ssh.PublicKeys(signer)For an encrypted key, ParsePrivateKey returns a PassphraseMissingError; use ParsePrivateKeyWithPassphrase. An agent is better for people, and a key file with tight permissions fits an unattended service account.
Never skip host verification
ssh.InsecureIgnoreHostKey() appears in many snippets because it makes the example shorter. It accepts any key, so a network attacker can impersonate the server and capture the session. The cost of doing it right is the three lines above. See verifying host key fingerprints.
Where this does not apply
For a handful of commands in a script, the system ssh with a config file and multiplexing is simpler and gets every OpenSSH feature, including jump hosts and certificates. The Go library does not read ~/.ssh/config, so aliases, ProxyJump and IdentityFile must be implemented in your code. Many servers at once is where the library earns its place; run sessions concurrently, one connection per host. See running remote commands over SSH for the shell route.
How the library fits together
SSH in Go comes from golang.org/x/crypto/ssh, which is maintained alongside the standard library and is not part of it, so you add it as a module dependency. The shape is: build a ClientConfig with a user, a list of authentication methods and a host key callback, call ssh.Dial to get a client, open a session on it, and run a command. One session runs one command, so a script that runs several commands opens a session for each, over the same connection. Set Timeout in the config so a dead host fails in seconds and not after the operating system's default, which can be minutes. Use CombinedOutput for simple cases, and set Stdout and Stderr separately when you need them apart, or when the output is large and should stream.
The exit status of the remote command comes back as an *ssh.ExitError from Run, with ExitStatus() to read the code. A nonzero status is an error value and not a failure to connect, so a caller can tell "the command ran and failed" from "the connection failed", and should.
Authentication in practice
Three methods cover most uses. The SSH agent, reached through the socket named in SSH_AUTH_SOCK, lets the program use keys that are already loaded without ever reading a private key file, which is the best option on a workstation. A key file is read, parsed with ssh.ParsePrivateKey and offered with ssh.PublicKeys. A key protected by a passphrase needs ParsePrivateKeyWithPassphrase, and the passphrase must come from somewhere safe, such as a prompt that does not echo, never from a constant in the source. Passwords work with ssh.Password, and are the weakest choice; many servers disable them. List several methods, and the library tries them in order, so an agent first and a file second is a sensible chain. Keys on disk should be mode 600, and the program can refuse to use a key that is group or world readable, as OpenSSH does.
For automation, give the program its own key with access to only what it needs, and restrict that key on the server with options in authorized_keys, such as command= and from=, so a stolen key can do little.
Host keys are the part that protects you
The host key callback is how the client decides it is talking to the right server. The convenience function ssh.InsecureIgnoreHostKey accepts any key and turns the connection into one that anyone on the path can impersonate, and it appears in far too many examples. Use knownhosts.New from golang.org/x/crypto/ssh/knownhosts with the path to your known hosts file, which gives the same behavior as OpenSSH: a known key passes, a changed key fails with an error that you should treat as an alarm, and an unknown host fails unless you decide how to handle it. For a first connection, show the fingerprint and require a person to approve it, or pin the expected key in the program's configuration. Obtain the fingerprint through another channel, such as the provider's console, rather than trusting what the first connection shows.
Keep the known hosts file for the program separate from the user's, if it runs unattended, and populate it deliberately with ssh-keyscan output that you have verified.
Running commands and handling the results
Treat each remote command as a separate unit with its own timeout and its own result. Wrap the call so it returns the output, the exit status and any error, and let the caller decide what to do. Run commands as an array of arguments quoted properly, since the SSH protocol sends one command string, which the remote shell parses, so a file name with a space or a semicolon needs quoting on your side before it is sent. Build the string with a function that quotes each argument and never concatenate user input directly, or a value like foo; rm -rf ~ becomes two commands. For many hosts, run them concurrently with a bounded pool and collect results per host, with a summary of failures at the end.
Reuse the connection for several commands on the same host, since the handshake and key exchange cost more than a command. Close sessions and the client in defer statements so descriptors do not leak when a command fails, and handle context cancellation by closing the client from another goroutine, since the library's calls do not take a context directly.
Finally, log what you run and where, without secrets, so a failed run can be traced to a host and a command.
See the ssh package and knownhosts.


