Skip to content
shellhacks.dev
SSH and Networking

SSH From the Basics to a Hardened and Scripted Setup

An SSH guide from first login to a hardened, scripted setup: keys, config files, agents, tunnels, jump hosts, sshd hardening, automation and troubleshooting.

36 min read
Summarize in AI
Illustration for "SSH From the Basics to a Hardened and Scripted Setup": a shell script in an editor, a padlock and key, an exit status badge.

SSH is the tool that almost every server administrator and developer uses every day, and most people use about a tenth of it. Beyond logging in, it moves files, tunnels ports, reaches machines behind bastions, runs commands across fleets and, configured well, is one of the strongest controls on a server. The examples here assume OpenSSH 9.2p1, the version shipped with Debian 12, and note where newer or older releases differ. Running SSH from scripts, the part that often goes wrong, is covered in depth near the end; the scripts themselves follow the Bash scripting tutorial.

The guide covers how SSH establishes trust, creating and using keys, the agent, the client config file, file transfer, port forwarding, jump hosts, hardening the server, certificates and restricted keys, scripting, long-lived sessions, and troubleshooting, with the failure modes for each.

How an SSH connection works

An SSH connection does three things in order. First, the two sides negotiate algorithms and the server proves its identity by presenting a host key, which the client checks against its list of known hosts. Second, they derive session keys by key exchange, so that everything afterwards is encrypted and integrity-protected. Third, the client authenticates the user, most often with a public key: the server sends a challenge that only the holder of the private key can answer. After that, the connection carries one or more channels: an interactive shell, a single command, a file transfer subsystem or a forwarded port. The protocol is specified in RFC 4251 and the following RFCs in the 4250 range.

Two checks protect you and they are different. The host key check protects the client against connecting to an impostor server. The user authentication protects the server against an impostor client. A common mistake is to treat the first one as an annoyance: the prompt about an unknown host fingerprint is the entire defence against a man-in-the-middle on the first connection, and answering yes without comparing the fingerprint gives that defence up.

Host keys and known_hosts

The first time you connect, the client shows the server's host key fingerprint and asks whether to trust it. If you accept, the key is stored in ~/.ssh/known_hosts. On every later connection, the client compares the presented key against the stored one, and if they differ it refuses with a loud warning that the host identification has changed. That warning has two causes: the server was reinstalled or its keys were rotated, or someone is intercepting the connection. Do not delete the entry and carry on without finding out which.

Shell
ssh-keyscan -t ed25519 server.example.org 2>/dev/null | ssh-keygen -lf -
ssh-keygen -F server.example.org
ssh-keygen -R server.example.org
Text
256 SHA256:Fh3k...q9Z server.example.org (ED25519)

Get the fingerprint over a second channel, such as the cloud provider's console output, and compare it to what the client shows before you accept. ssh-keygen -F finds an entry, and -R removes one after you have confirmed the change is legitimate. Setting HashKnownHosts yes, the Debian default, stores hostnames hashed so a stolen file does not list every machine you have visited, at the cost of readability. Do not set StrictHostKeyChecking no in scripts to avoid the prompt; use StrictHostKeyChecking accept-new, which accepts unknown hosts but still refuses a changed key, or distribute known_hosts in advance.

Create a key pair

A key pair has a private half that never leaves your machine and a public half you install on servers. Ed25519 is the right default: small keys, fast operations and no parameter choices to get wrong. RSA remains supported and needs at least 3072 bits. DSA is gone from current releases and ECDSA is best avoided.

Shell
ssh-keygen -t ed25519 -a 100 -C "laptop 2026" -f ~/.ssh/id_ed25519
Text
Generating public/private ed25519 key pair.
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /home/dev/.ssh/id_ed25519
Your public key has been saved in /home/dev/.ssh/id_ed25519.pub
The key fingerprint is:
SHA256:Qm2...tX0 laptop 2026

Always set a passphrase. It encrypts the private key on disk, so a copied or stolen file is useless without it. The -a 100 option raises the number of key derivation rounds, which slows brute-force attempts on the passphrase. The comment after -C is only a label that helps you recognize the key later in authorized_keys. Use a separate key per device and per purpose, so that revoking one lost laptop does not mean replacing every credential you own.

Install the public key on a server

ssh-copy-id appends your public key to the server's ~/.ssh/authorized_keys, creating the directory and fixing permissions if needed. It needs a working login first, so the first copy uses a password or an existing key.

Shell
ssh-copy-id -i ~/.ssh/id_ed25519.pub [email protected]
ssh [email protected] 'hostname; id -un'
Text
Number of key(s) added: 1

Now try logging into the machine, with:   "ssh '[email protected]'"
and check to make sure that only the key(s) you wanted were added.

server01
deploy

Without ssh-copy-id, the manual equivalent is cat key.pub | ssh user@host 'mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys'. Use >> and not >, or you replace every other authorized key. On a cloud instance, add keys through the provider's mechanism where one exists, as later maintenance can overwrite files written by hand.

Permissions SSH insists on

With StrictModes yes, the default, the server refuses to use an authorized_keys file if the file, the .ssh directory or the home directory is writable by anyone but the owner, since someone else could add a key. The client likewise refuses to use a private key that other users can read. The messages mention "bad permissions" or "UNPROTECTED PRIVATE KEY FILE". The fix is a short list of commands, and it resolves most "key login does not work" reports.

Shell
chmod 755 ~
chmod 700 ~/.ssh
chmod 600 ~/.ssh/id_ed25519 ~/.ssh/authorized_keys ~/.ssh/config
chmod 644 ~/.ssh/id_ed25519.pub ~/.ssh/known_hosts
ls -ld ~ ~/.ssh ~/.ssh/*

On the server, also check ownership: the files must belong to the login user, and the home directory must not be group-writable. SELinux can block reading authorized_keys with the wrong context even when the permissions are right; on Fedora and RHEL, restorecon -R ~/.ssh repairs it. The server's log, covered under troubleshooting, states which check failed.

The agent

Typing a passphrase for every connection gets old, and removing the passphrase is the wrong fix. ssh-agent holds decrypted keys in memory and answers signing requests, so you enter the passphrase once per session. Add a key with ssh-add, list loaded keys with ssh-add -l, and remove them with ssh-add -D. Most desktop systems start an agent at login; on a plain server start one with eval "$(ssh-agent -s)".

Shell
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519
ssh-add -l
ssh-add -t 4h ~/.ssh/id_work
Text
Agent pid 4412
Identity added: /home/dev/.ssh/id_ed25519 (laptop 2026)
256 SHA256:Qm2...tX0 laptop 2026 (ED25519)

The -t option sets a lifetime after which the key is dropped from the agent. On macOS, UseKeychain yes with AddKeysToAgent yes in the config stores the passphrase in the system keychain. Anyone who can act as you on the machine can use the agent while the keys are loaded, even without knowing the passphrase, so lock the screen and set lifetimes on sensitive keys. Hardware-backed keys (ed25519-sk, created with ssh-keygen -t ed25519-sk and a FIDO2 token) remove the file from the threat model entirely, because the private key cannot leave the device and each use requires a touch.

The client config file

~/.ssh/config turns a long command line into a name. Each Host block gives a pattern and the options that apply to matching hosts. The first value found for an option wins, so put specific blocks first and general defaults, under Host *, at the end.

Text
Host web
    HostName web01.internal.example.org
    User deploy
    Port 2222
    IdentityFile ~/.ssh/id_ed25519
    IdentitiesOnly yes

Host *.example.org
    User admin
    ServerAliveInterval 30

Host *
    AddKeysToAgent yes
    HashKnownHosts yes

With this file, ssh web runs the equivalent of ssh -p 2222 -i ~/.ssh/id_ed25519 [email protected]. IdentitiesOnly yes tells the client to offer only the named key, not every key in the agent, which avoids "too many authentication failures" when the agent holds many keys and the server counts each attempt. Use ssh -G web to print the options that will apply to a host after all blocks are merged; it is the fastest way to see why a setting is not what you expected. The reference is ssh_config(5).

Running a command and passing arguments

Anything after the host is a command to run on the remote side. SSH joins the arguments with spaces and hands the string to the remote user's login shell, which parses it again. That second parse is the source of the most common SSH scripting bug: quoting is applied twice, once by your local shell and once by the remote one.

Shell
ssh web 'uptime -p'
ssh web "ls /var/log | wc -l"
ssh web ls -d /srv/*          # glob expanded locally, not on web
ssh web 'ls -d /srv/*'        # glob expanded on web
file="my report.txt"
ssh web "rm -- $file"                    # splits on the remote side
ssh web "rm -- $(printf '%q' "$file")"   # quoted for the remote shell

Single quotes keep your local shell from expanding anything, and the remote shell then does it. Double quotes expand variables locally, which is right for passing values in but wrong for remote variables and globs. For data from variables, printf '%q' produces a form that the remote Bash will parse back to the original string. A cleaner way to send structured work is to pipe a script to a remote shell, ssh web 'bash -s' -- arg1 arg2 < local-script.sh, so that the script text is not parsed as a command line at all.

Copy files: scp, sftp and rsync

scp copies files with the syntax of cp. Since OpenSSH 9.0 it uses the SFTP protocol internally by default, which fixed long-standing quoting problems with remote paths. sftp gives an interactive session. rsync -e ssh transfers only differences, preserves attributes, resumes and deletes extra files on request, which makes it the better tool for anything repeated or large.

Shell
scp report.pdf web:/srv/reports/
scp -r web:/var/log/app ./logs
rsync -avz --partial --progress ./site/ web:/srv/site/
rsync -avz --delete --dry-run ./site/ web:/srv/site/

The trailing slash on the source in rsync matters: site/ copies the contents, while site copies the directory itself into the destination. Always run the first --delete transfer with --dry-run, since a wrong destination path with delete removes real files. For one-off large copies over a flaky link, rsync's --partial keeps what arrived so a retry resumes. To copy between two remote hosts, scp's -3 routes traffic through your machine, which avoids needing a trust relationship between the servers.

Local port forwarding

Port forwarding carries other protocols through the encrypted channel. A local forward, -L, listens on a port on your machine and delivers connections to a destination as seen from the SSH server. It is how you reach a database or an admin page that is only listening on a private network.

Shell
ssh -N -L 5433:db.internal:5432 bastion
psql -h 127.0.0.1 -p 5433 -U app appdb

The forward binds to the loopback address by default, so only processes on your own machine can use it. The target db.internal:5432 is resolved and connected from the bastion, which is why a private name works. -N means no remote command, which is right for a forward-only session, and -f sends it to the background after authentication. Binding to 0.0.0.0 with -L 0.0.0.0:5433:... exposes the tunnel to your whole network and is rarely what you want; the server must also allow it with GatewayPorts, and the client setting applies only when the server permits.

Remote port forwarding

A remote forward, -R, listens on a port on the SSH server and sends connections back through the tunnel to a destination reachable from your machine. It is how you expose a local development server to a colleague through a shared host, or reach a machine behind NAT from a server with a public address.

Shell
ssh -N -R 8080:localhost:3000 public.example.org
ssh -N -R 2222:localhost:22 public.example.org

The second line makes the laptop's SSH port reachable as port 2222 on the public host, so from there ssh -p 2222 localhost reaches the laptop. By default the remote end listens on loopback only. To accept connections from other machines the server needs GatewayPorts yes or clientspecified, and you should think hard before turning that on. A remote forward is also a way for a compromised or careless user to open a path into a protected network, which is why hardened servers set AllowTcpForwarding no for accounts that do not need it.

Dynamic forwarding as a SOCKS proxy

-D turns the SSH connection into a SOCKS proxy. Applications that speak SOCKS5 send their traffic through the tunnel, and the remote host makes each connection. Browsers and curl support it.

Shell
ssh -N -D 1080 bastion
curl --socks5-hostname 127.0.0.1:1080 http://intranet.internal/health

Use --socks5-hostname and not --socks5 so the name is resolved on the far side of the tunnel; otherwise your machine tries to resolve a private name and fails or, worse, leaks the lookup. A SOCKS tunnel is a flexible way to reach many internal services without writing a forward for each, and equally a way to give any application on your laptop the bastion's network position, so keep it bound to loopback and close it when finished.

Jump hosts with ProxyJump

A bastion, or jump host, is a hardened machine that is the single entry point to a private network. ProxyJump (the -J flag) connects to the bastion first and then opens the connection to the target through it. The second connection is a separate end-to-end encrypted session from your client to the target: the bastion forwards bytes and never sees the plaintext, and your keys never have to be on it.

Text
Host bastion
    HostName bastion.example.org
    User admin

Host db01
    HostName 10.0.4.21
    User dba
    ProxyJump bastion
Shell
ssh db01
ssh -J [email protected] [email protected]
scp -J bastion backup.sql [email protected]:/tmp/

ProxyJump replaces the older pattern of ProxyCommand ssh -W %h:%p bastion and the dangerous habit of agent forwarding to the bastion. Chains work too: -J first,second. Everything that takes an SSH target, including scp, rsync with -e ssh, and Git over SSH, goes through the jump when the config block says so. Keep authentication per hop in mind: each hop uses the identity configured for it, so an IdentityFile set on Host * will be offered to every one.

Agent forwarding and why to avoid it

Agent forwarding, -A or ForwardAgent yes, lets processes on the remote host use your local agent. It is convenient for hopping onward from a server, and risky: root on that server, or anyone who can read the agent's socket, can ask your agent to sign authentication requests for as long as the connection lasts. They cannot copy the key, but they can log in as you wherever the key is accepted. If you need to reach a second machine through the first, use ProxyJump instead, which does not expose the agent at all.

Text
# Dangerous default
Host *
    ForwardAgent yes

# Safer: forward only to a host you control and trust
Host trusted-build
    ForwardAgent yes

If you must forward, forward narrowly, set a lifetime on the key with ssh-add -t and consider ssh-add -c, which asks for confirmation on each use. With FIDO2 keys the touch requirement limits abuse to the moments you are present. Git hosting is a common reason people forward the agent; a deploy key or a short-lived token on the build host is a safer pattern than lending your identity.

Connection multiplexing and keepalives

Each new SSH connection pays for a TCP handshake, key exchange and authentication, which adds up when a script makes dozens of calls or when you repeatedly use scp and ssh against one host. Multiplexing shares one authenticated connection among later sessions. The first connection becomes a master and creates a control socket, and later ones attach to it almost instantly.

Text
Host *
    ControlMaster auto
    ControlPath ~/.ssh/cm-%C
    ControlPersist 10m
    ServerAliveInterval 30
    ServerAliveCountMax 3
Shell
ssh -O check web
ssh -O exit web

With ControlPersist 10m the master stays alive for ten minutes after the last session, so a second command a minute later connects in milliseconds. %C hashes the connection parameters into a short, fixed-length socket name, which avoids the path length limit of Unix sockets. The keepalive options send a message every 30 seconds and give up after three misses, which keeps NAT devices from dropping an idle connection and tells you quickly when a link has died. A multiplexed session shares the master's authentication, so a stale master after a network change shows up as a hang; ssh -O exit clears it.

Harden the server: sshd_config

The server's behavior is set in /etc/ssh/sshd_config and files under /etc/ssh/sshd_config.d/. A few settings remove most of the risk of an internet-facing SSH port. Turn off password authentication, so there is nothing to guess. Turn off root login, so an attacker must also know a real username and then escalate. Limit the users who may log in at all. Reduce the window for unauthenticated connections.

Text
# /etc/ssh/sshd_config.d/10-hardening.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
AllowUsers deploy admin
MaxAuthTries 3
LoginGraceTime 20
ClientAliveInterval 300
ClientAliveCountMax 2
X11Forwarding no
AllowAgentForwarding no

On Debian 12 the main file contains Include /etc/ssh/sshd_config.d/*.conf near the top, and for most options the first value read wins, so a drop-in that sorts before other files takes precedence; naming it with a low number, as above, matters. Check what the server will actually use with sudo sshd -T, which prints the effective configuration after all includes and Match blocks. The meaning of each keyword is in sshd_config(5), and published benchmarks list further settings.

Apply changes without locking yourself out

A mistake in sshd_config can lock you out of a remote machine, so use a routine that cannot fail silently. Validate the syntax first with sshd -t, which prints nothing if the file is fine. Reload, not restart, so existing sessions survive. Then open a second session, and only close the first when the new one works. Keep a recovery path outside SSH, such as a cloud console or a hypervisor shell, before touching the settings on a remote host.

Shell
sudo sshd -t && echo "config ok"
sudo systemctl reload ssh
ssh -o PreferredAuthentications=publickey -o PasswordAuthentication=no [email protected] true
sudo sshd -T | grep -Ei 'permitrootlogin|passwordauthentication|allowusers'
Text
config ok
permitrootlogin no
passwordauthentication no
allowusers deploy admin

The unit is called ssh on Debian and Ubuntu and sshd on Fedora and RHEL. The test connection forces public key authentication and disables passwords, so a success proves the key path works and does not depend on a fallback you are about to remove. If a change goes wrong and you hold an open session, you can still fix it, because reloading never kills established connections.

Change the port, rate-limit and ban

Moving SSH off port 22 reduces log noise from automated scanners but does not make a weak configuration secure; it is obscurity, not protection, so do it after the real measures. A firewall rule that allows port 22 only from known addresses is much stronger. For hosts that must be reachable from anywhere, rate limiting and automatic bans add a layer: fail2ban watches the auth log and adds firewall rules for addresses with repeated failures, and nftables or ufw can rate-limit new connections to the port.

Shell
sudo ufw limit 22/tcp
sudo fail2ban-client status sshd
sudo journalctl -u ssh --since '1 hour ago' | grep -c 'Failed'

With password login off, failed attempts cost only log lines and CPU, since a guess cannot succeed, so the main value of banning is cleaner logs and a signal for alerts. The greater risk becomes a stolen key, which is why passphrases, short-lived certificates and the revocation of old keys matter more than the port number.

Restrict what a key can do

Each line of authorized_keys can carry options before the key. They limit what that key may do, so a key used by an automated job cannot be turned into a general login. command="..." forces a specific command regardless of what the client asked for. from="..." limits source addresses. restrict disables forwarding, PTY allocation and more in one word, and further options re-enable only what you list. no-port-forwarding and friends are the older individual switches.

Text
restrict,from="203.0.113.0/24",command="/usr/local/bin/backup-receiver" ssh-ed25519 AAAAC3Nza... backup@host

This key can be used only from one network, can run only the receiver script, and gets no shell, no forwarding and no terminal. Inside the forced command, the original request is available in $SSH_ORIGINAL_COMMAND, which a script can validate against an allow-list; never pass it straight to a shell. Treat the forced command as the security boundary and write it as carefully as a network service, as discussed in the shell script security guide.

SSH certificates

Past a handful of servers and users, managing authorized_keys files and known_hosts entries becomes the work. SSH certificates replace both with a certificate authority. You create a CA key, sign user public keys into short-lived certificates with an identity and principals, and servers trust the CA instead of individual keys. Likewise, the CA signs each server's host key, and clients trust the CA for hosts, which removes the first-connection prompt entirely.

Shell
ssh-keygen -t ed25519 -f ca_user -C "user CA"
ssh-keygen -s ca_user -I [email protected] -n deploy -V +8h ~/.ssh/id_ed25519.pub
ssh-keygen -L -f ~/.ssh/id_ed25519-cert.pub
Text
        Type: [email protected] user certificate
        Key ID: "[email protected]"
        Valid: from 2026-11-05T09:00:00 to 2026-11-05T17:00:00
        Principals:
                deploy

On the server, TrustedUserCAKeys /etc/ssh/ca_user.pub in sshd_config makes it accept any certificate signed by that CA whose principal matches the login. A certificate that expires in eight hours needs no revocation list in practice, which is its great advantage over a key that lives for years. The CA key becomes the crown jewel and belongs on an offline machine or behind a signing service. The design is documented in the CERTIFICATES section of ssh-keygen(1).

Script SSH safely

Scripts need SSH to fail, not to prompt. The option set that does this is BatchMode=yes, which disables every prompt, with ConnectTimeout so an unreachable host does not hang the run, and StrictHostKeyChecking accept-new or a pre-populated known_hosts.

Shell
#!/usr/bin/env bash
set -euo pipefail
opts=(-o BatchMode=yes -o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new)

while IFS= read -r host; do
  [[ -z $host || $host == \#* ]] && continue
  if out=$(ssh -n "${opts[@]}" "$host" 'uptime -p' 2>&1); then
    printf '%-20s %s\n' "$host" "$out"
  else
    printf '%-20s FAILED: %s\n' "$host" "$out" >&2
  fi
done < hosts.txt
Text
web01                up 12 days, 3 hours
web02                up 12 days, 3 hours
db01                 FAILED: ssh: connect to host db01 port 22: Connection timed out

The -n flag is essential inside a while read loop. Without it, ssh reads from standard input, which is the host list, and swallows the rest of the file, so the loop runs for the first host only. That one-host-then-stop symptom is a classic. The options are kept in an array so each stays a separate word, and the failure of one host is reported without stopping the others.

Run a script remotely

To run more than one command, send a script on standard input to a remote shell. This avoids layered quoting, because the script is data. Pass arguments after -s --. Use a quoted here document so the local shell does not expand variables you meant for the remote one.

Shell
ssh web 'bash -s' -- /srv/app <<'EOF'
set -euo pipefail
dir="$1"
cd "$dir"
git rev-parse --short HEAD
systemctl is-active app.service
EOF
Text
3f9a2c1
active

Because the delimiter is quoted, $1 and $dir reach the remote shell untouched. With an unquoted delimiter, your local shell would expand them first and they would be empty. Allocate a terminal with -t only when the remote command needs one, such as sudo prompting for a password or a curses program; in a script a pseudo-terminal merges standard error into standard output and mangles line endings.

Parallel runs and fleet commands

To run a command on many hosts, combine xargs -P with the batch-safe options and capture each host's output separately, so a failure on one is attributable. Tools such as Ansible and pdsh exist for this at scale, and SSH multiplexing keeps the per-host cost low in a short script.

Shell
printf '%s\n' web01 web02 web03 db01 |
  xargs -P4 -I{} sh -c 'ssh -n -o BatchMode=yes -o ConnectTimeout=8 {} "df -h / | tail -1" > "out.{}" 2>&1 || echo "{} failed" >&2'
cat out.web01 out.web02

A limit of four at a time keeps the control machine and the network calm, and keeps output from interleaving. When you reach the point of needing inventory, retries, idempotent changes and reports, adopt a configuration management tool and stop growing the shell version. The same applies to credentials: a loop that reaches every server with one key makes that key a skeleton key, which is a case for per-user certificates with short lifetimes.

Keep sessions alive with tmux

An SSH session dies when the connection does, and takes its processes with it. For long work, start tmux on the server and reattach after a drop. The session survives disconnects, and you can resume from a different machine.

Shell
ssh web -t 'tmux new -A -s main'

new -A -s main attaches to a session named main if it exists and creates it if not, so the same command works every time. Add ServerAliveInterval on the client to detect dead links and avoid a frozen terminal; to escape a hung session press Enter, then ~ and . (the escape sequence), which closes the connection without waiting. Mosh is an alternative for roaming and high-latency links, using UDP after an SSH handshake, though it needs its own server component and open ports.

Troubleshoot a failing connection

Work from the client outwards. Verbose output shows each step; add more v for more detail. Read the config the client will really use with ssh -G host. Then read the server's log, because the server knows why it rejected you and tells only its log, not the client.

Shell
ssh -vvv [email protected] 2>&1 | grep -Ei 'offering|authentications|permission|denied|identity'
ssh -G web | grep -Ei '^(hostname|user|port|identityfile|proxyjump)'
sudo journalctl -u ssh -n 30 --no-pager
sudo sshd -ddd -p 2222
Text
debug1: Offering public key: /home/dev/.ssh/id_ed25519 ED25519 SHA256:Qm2...
debug1: Authentications that can continue: publickey
Permission denied (publickey).

The common causes, in rough order of frequency: wrong permissions on the server's .ssh files or home directory; the key not in authorized_keys or a different key offered than expected, which IdentitiesOnly and -i resolve; the server refusing the user through AllowUsers or DenyUsers; password login disabled before keys worked; a changed host key; a firewall or security group dropping the port; and the account being locked. A temporary second sshd -ddd -p 2222 on an alternate port runs in the foreground with full debugging and lets you test without touching the live daemon.

Common mistakes and their fixes

  • No passphrase on the private key. Add one with ssh-keygen -p, and use an agent.
  • Agent forwarding to every host. Use ProxyJump and forward only to hosts you control.
  • Disabling host key checking in scripts. Use accept-new or ship known_hosts.
  • Locking yourself out. Run sshd -t, reload, and test in a second session.
  • Pasting a key with a line break. A public key is one line; verify with ssh-keygen -lf.
  • ssh inside a loop eating the input. Add -n.
  • Double-quoting problems in remote commands. Use single quotes, printf '%q' or a script on standard input.
  • A loose home directory. Remove group and world write permission.
  • Using one key everywhere. One key per device and per purpose, rotated when a device is lost.
  • Treating a changed-host-key warning as noise. Find out why it changed.

Almost all are a variant of one problem: SSH trusts the file system and the shell, so anything loose in either becomes an authentication or injection gap.

Rotate and revoke keys

Keys outlive the people and laptops they were made for, so plan their end from the start. Keep a record of which public key belongs to which person and device, using the comment field as a label. When a device is lost or someone leaves, remove their line from every authorized_keys file they were in. On a fleet that is where a central source of truth pays off: if the files are generated from one repository, revocation is a commit and a deploy. For a key that must be rejected everywhere at once, even if it is still listed, point RevokedKeys in sshd_config at a file of revoked public keys, and build that file with ssh-keygen -k. Certificates make this largely unnecessary, because a short lifetime does the revoking for you.

Shell
ssh-keygen -lf ~/.ssh/authorized_keys
awk '{print $NF}' ~/.ssh/authorized_keys
grep -c '^ssh-' ~/.ssh/authorized_keys

Periodically audit what is authorized. A list of public keys with unfamiliar comments or a key nobody can account for is an incident to look into, not clutter to ignore. Rotation of your own key is equally routine: generate a new key, install it everywhere, test it, remove the old one, and delete the old private file. Do this on a schedule, such as yearly, and after any suspected exposure.

Git over SSH and deploy keys

Git hosting services accept SSH keys for pushes and pulls, and the same config file rules apply. If you use several accounts on one host, give each its own key and a distinct Host alias, then use the alias in the remote URL. A deploy key is a key attached to a single repository, usually read-only, which suits CI servers and production hosts that must pull code but should not be able to read anything else.

Text
Host github-work
    HostName github.com
    User git
    IdentityFile ~/.ssh/id_work
    IdentitiesOnly yes
Shell
git clone git@github-work:acme/tools.git
ssh -T git@github-work

The IdentitiesOnly line matters here: without it the client offers every agent key in turn, and the host may authenticate you as the wrong account on the first one it recognises. Create deploy keys without a passphrase only when the host stores them with the same care as any other secret, restricts them to read access and logs their use. Any key without a passphrase is a bearer credential, so its file permissions and the machine's own security are all that protect it.

Log and audit SSH use

The server logs authentication events to the journal or /var/log/auth.log. The default LogLevel INFO records who logged in and from where; VERBOSE also records the fingerprint of the key used, which is how you tell which of a user's several keys was used and trace a login to a specific device. Turn it on for any host that matters.

Text
LogLevel VERBOSE
Shell
sudo journalctl -u ssh --since today | grep -E 'Accepted|Failed|Invalid user'
last -n 10
lastb -n 10
who -u
Text
Nov 05 10:12:44 server01 sshd[2211]: Accepted publickey for deploy from 203.0.113.9 port 51522 ssh2: ED25519 SHA256:Qm2...tX0

Forward these logs off the host, so an attacker who gains access cannot erase them, and alert on patterns that matter: a login from an unexpected country, a root attempt, a burst of failures followed by a success. For a stronger record, auditd rules or session recording tools capture commands, with the privacy implications that carries. Decide what you record and who may read it before you start.

Restrict file transfer accounts

Some accounts exist only to exchange files. Give them SFTP and nothing else, confined to a directory. In sshd_config a Match block applies settings to a group, using the built-in SFTP server and a chroot.

Text
Match Group sftponly
    ChrootDirectory /srv/sftp/%u
    ForceCommand internal-sftp
    AllowTcpForwarding no
    X11Forwarding no
    PermitTTY no

The chroot directory and each directory above it must be owned by root and not writable by anyone else, or sshd refuses the connection with a fatal error about bad ownership. Create a subdirectory inside it that the user owns for uploads. This is the most common stumbling block for the setup, and the log line states it precisely: "bad ownership or modes for chroot directory". The user has no shell, cannot reach the rest of the filesystem and cannot forward ports, so a leaked credential exposes one directory.

Check cipher and algorithm choices

Modern OpenSSH defaults are sound, and the best approach is to keep the software updated rather than to hand-pick algorithms. You can still inspect what your client and server support and see what a connection negotiated. ssh -Q lists supported algorithms by type, sshd -T shows the server's effective lists and ssh -vv prints the negotiated ones. Remove legacy options only when you have checked that no client you need depends on them.

Shell
ssh -Q kex | head -5
ssh -Q cipher | head -5
ssh -vv web true 2>&1 | grep -E 'kex: (algorithm|host key)|cipher:'

A hand-written Ciphers or KexAlgorithms list freezes today's judgement into config, and if a listed algorithm is later removed from the software, sshd refuses to start. Prefer the + and - prefix forms, such as KexAlgorithms -diffie-hellman-group14-sha1, which remove one choice from the default list and keep the rest current. Post-quantum key exchange became the default in OpenSSH 9.0 with [email protected], so keeping the software current brings protections that a frozen list would miss.

Mount a remote directory and use SSH as a pipe

Two more uses are worth knowing. sshfs mounts a remote directory through SFTP, which is convenient for editing files with local tools, though not fast and not suited to heavy disk traffic. And SSH carries any byte stream, so you can pipe data across hosts without temporary files: tar on one end and tar on the other, or a database dump piped straight into a restore.

Shell
mkdir -p ~/mnt/web && sshfs web:/srv/site ~/mnt/web
fusermount3 -u ~/mnt/web
tar -czf - /srv/site | ssh backup 'cat > /backups/site.tgz'
ssh web 'pg_dump appdb | gzip' > appdb.sql.gz

With the last form the compressed dump is produced on the server and streamed to a local file, never written to the server's disk. The pipeline's exit status is that of the last command, so a failed pg_dump on the far side is invisible locally; check the size of the result, or run the remote side under set -o pipefail by wrapping it in bash -c, before trusting such a backup.

A short checklist for a new server

When a new machine comes up, a fixed sequence turns the defaults into a hardened setup in about ten minutes, and doing it the same way each time is what makes it reliable. First, log in with the provider's key and create a named admin user with sudo rights. Second, install your public key for that user and confirm that you can log in as them in a second terminal. Third, write the hardening drop-in shown above, run sshd -t, reload, and confirm again from a third terminal that key login still works and password login is refused. Fourth, restrict the firewall so that SSH is reachable only from the networks that need it. Fifth, enable automatic security updates, since the SSH daemon is the most exposed service on the host. Sixth, note the host key fingerprint where your team can find it, so that the next person connecting can compare it.

Each step is small, and the order matters: never remove the old way in until the new one is proven. The same checklist works as a script run by your provisioning tool, with the verification steps as assertions, and keeping it in version control makes the baseline reviewable.

Match blocks and conditional client config

The client config can do more than name hosts. A Match block applies options when a condition holds: the target host, the local user, whether a command succeeds, or whether the connection is going through a given network. Together with Include, which pulls in other files, it lets one config adapt to where you are and keeps work and personal settings in separate files.

Text
# ~/.ssh/config
Include ~/.ssh/config.d/*.conf

Match host *.corp.example.org exec "test -f ~/.ssh/at-office"
    ProxyJump none

Match host *.corp.example.org
    ProxyJump bastion.corp.example.org
    User %r

Match originalhost db* user dba
    RequestTTY no

The first block says that when a marker file exists, meaning you are on the office network, connect directly with no jump; the second sends everything else through the bastion. Because the first value found for an option wins, the order of the blocks is the logic. Use ssh -G host to print the result of all matching blocks, and read it whenever a connection takes an unexpected route. The tokens %h for the host, %r for the remote user and %p for the port can be used in many options, which allows one block to serve a family of hosts.

Managing known_hosts at scale

A personal known_hosts file grows by itself. Across a team, trust on first use does not scale, and a new engineer typing "yes" thirty times is a poor defence. Better options exist. Publish host key fingerprints in DNS as SSHFP records and set VerifyHostKeyDNS yes, which works when DNS is protected by DNSSEC. Distribute a signed known_hosts file through configuration management. Or use host certificates, as in the certificates section: a single @cert-authority line in the global ssh_known_hosts trusts every host the CA has signed.

Shell
ssh-keygen -r server.example.org
ssh-keyscan -t ed25519,rsa server.example.org | tee -a /etc/ssh/ssh_known_hosts
ssh-keygen -lf /etc/ssh/ssh_known_hosts
echo '@cert-authority *.example.org ssh-ed25519 AAAAC3Nza...' | sudo tee -a /etc/ssh/ssh_known_hosts

The first command prints SSHFP records you can paste into a zone. A scanned key is only as trustworthy as the network path of the scan, so verify fingerprints out of band once and distribute from a trusted copy. When a legitimately rebuilt host changes its key, remove the old entry with ssh-keygen -R and record why. Preserving host keys across a rebuild, by saving /etc/ssh/ssh_host_* and restoring them, avoids the warning altogether and is standard practice for hosts that are reprovisioned often.

Using SSH from CI and automation

Automation needs a credential, and the safest SSH credential for it is one that can do almost nothing. Create a dedicated key for the pipeline, restrict it in authorized_keys to one command and one source network as in the earlier section, and keep it in the CI system's secret store. Load it into an agent for the duration of the job and remove it afterwards. Pin the server's host key in the job so the first connection is not blind.

Shell
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "$DEPLOY_KEY" | tr -d '\r' > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s\n' "$DEPLOY_HOST_KEY" > ~/.ssh/known_hosts
ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o BatchMode=yes deploy@"$DEPLOY_HOST" release "$GIT_SHA"
shred -u ~/.ssh/deploy_key

The host key comes from a secret or a file in the repository, so a changed host fails the job and is looked at, not accepted. The key file is removed at the end, and the forced command on the server means a leaked key can run one release command and nothing else. Better still, use short-lived certificates issued to the job by an identity provider, so there is nothing long-lived to leak. Never print the key in logs, and remember that a debug flag such as ssh -vvv reveals file names and fingerprints, not the key content.

Two-factor authentication for SSH

A key plus a passphrase is already two factors in one sense, something you have and something you know. Stronger setups add a second, independent factor. A FIDO2 key, with ed25519-sk or the resident -O resident variant, requires touching a hardware token for each login. For passwords-plus-code setups, a PAM module such as pam_google_authenticator asks for a time-based code, and AuthenticationMethods publickey,keyboard-interactive in sshd demands both a valid key and the code.

Shell
ssh-keygen -t ed25519-sk -O verify-required -C "yubikey 2026"
ssh-keygen -K
Text
# sshd_config
AuthenticationMethods publickey,keyboard-interactive
KbdInteractiveAuthentication yes
UsePAM yes

The verify-required option makes the token ask for its PIN as well as a touch. The second command downloads resident keys from a plugged-in token to a new machine. Test any change to authentication methods with a second session open, as in the lockout advice earlier, and keep a break-glass account or console access, since losing the token is a real event. Hardware keys protect against the theft of a key file from a laptop, which is the most common key compromise.

Auditing a server's SSH setup

Configuration drifts, and a scan finds what a read of the file missed. Compare the effective configuration from sshd -T against your baseline, scan from outside for what the server offers, and review who can log in. The ssh-audit tool connects and reports algorithms, key sizes and known weaknesses with recommendations.

Shell
sudo sshd -T | sort > effective.txt
diff baseline.txt effective.txt
ssh-audit server.example.org
nmap -p 22 --script ssh2-enum-algos server.example.org
awk -F: '$7 !~ /(nologin|false)$/ {print $1, $7}' /etc/passwd
sudo find /home -name authorized_keys -exec wc -l {} +

The awk line lists accounts with a real login shell and the find counts keys in each home directory; both quickly show accounts and keys nobody remembers. Keep the baseline file in version control. A hardened server that quietly regains PasswordAuthentication yes after a package upgrade or a restore of old configuration is a common finding, and a scheduled check that diffs against the baseline and alerts catches it, using the timers in the scheduling guide.

Common connection problems and what they mean

Most failures show up as a handful of messages. Reading them precisely cuts the guessing.

MessageUsual cause
Connection refusedNothing listening on the port, or sshd is stopped
Connection timed outFirewall or security group drops the traffic, or the address is wrong
No route to hostNetwork path problem or host down
Permission denied (publickey)Key not accepted: wrong key, permissions, user or sshd policy
Too many authentication failuresAgent offered many keys; use IdentitiesOnly yes
Host key verification failedHost key changed or missing in strict mode
Connection closed by ... port 22Server dropped you: AllowUsers, rate limits or a ban
kex_exchange_identificationServer closed early, often MaxStartups or a ban list
Broken pipeIdle connection dropped; set ServerAliveInterval

The distinction between refused and timed out is the quickest triage: refusal means a machine answered, so the problem is the daemon or the port, while a timeout means something in between is dropping packets. Test the port alone with nc -vz host 22 to separate network problems from SSH problems. Then the server's log tells you more than any client message.

Working from Windows and WSL

Windows 10 and 11 ship an OpenSSH client and a service for the agent, so ssh, scp and ssh-keygen work in PowerShell and the terminal. The config lives at %USERPROFILE%\.ssh\config and uses the same syntax, with the caveat that file permission checks use Windows ACLs and the agent is a separate service that must be started. Inside WSL, the Linux client works as on any Linux box, but it has its own ~/.ssh and its own agent; sharing a key between the two means copying it with the right permissions or bridging the agent. Keys on a Windows drive mounted into WSL show permissions that are too open, and ssh refuses them, so keep keys on the Linux filesystem.

Shell
Get-Service ssh-agent | Set-Service -StartupType Automatic
Start-Service ssh-agent
ssh-add $env:USERPROFILE\.ssh\id_ed25519
ls -l ~/.ssh   # inside WSL, on the Linux side

Line endings can also bite: a private key or authorized_keys file saved with Windows CRLF endings fails with an error about invalid format. Check with file and convert with dos2unix or tr -d '\r'. If you automate from a Windows host, prefer the built-in client over third-party ones, so that your config and the documentation in this guide apply unchanged.

Performance and large transfers

SSH is fast enough for most work and is the wrong tool for a few jobs. Throughput of a single connection is limited by the cipher, the CPU and the receive window on high-latency links; the default cipher selection, ChaCha20-Poly1305 or AES-GCM with hardware support, is already quick. For copying many small files, the per-file overhead dominates, and piping a tar stream is much faster than scp -r. For large transfers over lossy links, rsync with resume is better. For heavy repeated syncs between datacenters, a purpose-built tool or a VPN with a shared filesystem fits better than running everything through SSH.

Shell
tar -cf - site | ssh web 'tar -xf - -C /srv'
rsync -a --inplace --partial -e 'ssh -o Compression=no' big.img web:/srv/images/
ssh -o Compression=yes web 'cat /var/log/app.log' | gzip -d | wc -l

Compression helps on slow links with compressible text and hurts on fast links or already compressed data, where it only costs CPU, so turn it on per job. If a transfer is slow, find out whether the limit is the network, the disk or the CPU before changing anything, using the triage tools in the Linux command line guide.

Where this does not apply

Large organizations. Hand-managed keys do not scale to hundreds of engineers and thousands of hosts. Use certificates with short lifetimes and an identity provider, or a managed access broker, and audit sessions centrally.

Windows clients. Windows 10 and 11 ship an OpenSSH client and the agent runs as a service, but paths, permissions and the config location differ. PuTTY uses its own key format.

Containers. Running sshd inside a container to get a shell is almost always wrong. Use the orchestrator's exec facility, docker exec or kubectl exec, so there is no extra daemon, key or open port to maintain.

Untrusted jump hosts. ProxyJump protects the plaintext from the bastion, but a hostile bastion can still drop or delay traffic, and it learns who connects where. Trust it as you would any network device.

Very old servers. Legacy devices may only offer algorithms current clients disable, such as ssh-rsa signatures with SHA-1. Enable the old option for that one host in the config, and plan its replacement; do not lower the default for everything.

SSH is the transport for many jobs that you then want to run unattended, so continue to the guide to cron and systemd timers, and to make the scripts that use it safe, the shell script security guide. For the commands you will run once connected, see the Linux command line guide, and when a remote script misbehaves, the debugging guide.

Primary sources used here: RFC 4251, the SSH architecture, RFC 4252, SSH authentication, RFC 4254, the connection protocol, ssh(1), ssh_config(5), sshd_config(5), ssh-keygen(1), ssh-agent(1), scp(1), the OpenSSH release notes and the OpenSSH 9.0 notes on scp using SFTP.

Also inSecurity
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.