SSH¶
Level 4 · Chapter 5 · ⏱️ ~50 min read · Prerequisites: Networking basics, Firewalls with ufw, Permissions
SSH is how you log in to, manage, and move files between Linux machines. This chapter explains how SSH keeps a connection secure, then covers keys, the agent, client config files, server hardening, file copying with scp, sftp, and rsync, and tunnels.
Why it matters¶
Alex rents a cloud server and logs in with the password the provider emailed. A week later, /var/log/auth.log holds 40,000 lines like Failed password for root from 203.0.113.77. Bots are guessing passwords around the clock. Alex's password is decent, so nothing has happened yet. But "yet" is not a security strategy.
Alex spends fifteen minutes on four changes: an ed25519 key with a passphrase, ssh-copy-id to install it, PasswordAuthentication no on the server, and a ~/.ssh/config entry so that ssh web1 replaces ssh -p 2222 alex@203.0.113.10. The password-guessing bots now fail instantly, because the server no longer even asks for a password. Logging in got easier at the same time.
Later the same week, Alex needs to look at a database that only listens on the server's localhost. One ssh -L command makes it appear on the laptop's port 5432, encrypted end to end, without opening a single firewall port.
Concepts¶
What SSH is¶
SSH (Secure Shell) is a protocol for running commands on a remote machine, and for moving data to and from it, over an encrypted, authenticated connection. It replaced telnet and rsh, which sent everything, passwords included, as plain text.
There are two programs:
- the client,
ssh, which you run on your machine, and - the server,
sshd(SSH daemon), which runs on the remote machine and listens on TCP port 22 by default.
On Mint and Ubuntu, they come from two packages: openssh-client (always installed) and openssh-server (install it on machines you want to log in to). The server's systemd unit is called ssh.service, with sshd.service as an alias.
How an SSH connection is set up¶
Every SSH connection goes through three stages before you see a prompt. Understanding them explains every SSH warning and error message you will meet.
sequenceDiagram
participant C as Client (ssh)
participant S as Server (sshd)
C->>S: TCP connect to port 22
S->>C: Version banner (SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13)
C->>S: Version banner
Note over C,S: 1. Key exchange
C->>S: Supported algorithms + key exchange share
S->>C: Key exchange share + host public key + signature
Note over C: Verify host key against ~/.ssh/known_hosts
Note over C,S: Both derive the same session key. Everything is now encrypted
Note over C,S: 2. User authentication
C->>S: "I am alex, here is proof" (key signature or password)
S->>C: Success
Note over C,S: 3. Session
C->>S: Open channel: shell, command, sftp, or port forward
Stage 1: key exchange. The client and server agree on algorithms, then run a key exchange (such as Curve25519 Diffie-Hellman). Its magic is that both sides compute the same secret session key without ever sending it over the network. An eavesdropper sees the exchange and still cannot compute the key. From here on, everything is encrypted with that session key.
Stage 2: server authentication. Encryption alone is not enough: you could be talking, encrypted, to an attacker who intercepted your connection. This is called a man-in-the-middle (MITM) attack. To prevent it, the server proves its identity with its host key, a key pair generated when openssh-server was installed and stored in /etc/ssh/ssh_host_*_key. The server signs the key exchange with its private host key; the client checks the signature with the public host key and compares that key with the one it remembers for this server.
Stage 3: user authentication. Only now, inside the encrypted channel, do you prove who you are, with a key or a password.
Stage 4: the session. The connection carries one or more channels: an interactive shell, a single command, a file transfer, or a forwarded port. Many channels can share one connection.
Host keys, known_hosts, and TOFU¶
The client remembers servers' public host keys in ~/.ssh/known_hosts. The first time you connect to a server, there is nothing to compare with, so ssh asks you:
The authenticity of host '192.168.122.50 (192.168.122.50)' can't be established.
ED25519 key fingerprint is SHA256:3vQm7Ck1Xp4cKdYb0W9mJzq2fT8hR5nL6sE1aV0uGiQ.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])?
A fingerprint is a short hash of the public key, easy for humans to compare. If you answer yes, ssh saves the key and trusts it from now on. This model is called TOFU: trust on first use. It is safe as long as the very first connection is not intercepted.
To do better than TOFU, check the fingerprint through another channel. On the server (from its console, or as the cloud provider shows it), run:
If it matches what the client showed, the connection is genuine. You can even paste the fingerprint at the prompt instead of typing yes, and ssh compares them for you.
On later connections, ssh silently checks that the server presents the same key. If it does not, you get the most alarming message in SSH:
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
Someone could be eavesdropping on you right now (man-in-the-middle attack)!
It is also possible that a host key has just been changed.
...
Offending ED25519 key in /home/alex/.ssh/known_hosts:3
remove with:
ssh-keygen -f '/home/alex/.ssh/known_hosts' -R '192.168.122.50'
Host key verification failed.
ssh refuses to connect. Usually the reason is innocent: you reinstalled the server, or a cloud provider reused an IP address for a new machine. But it is exactly what a real attack looks like, so find out why before removing the old key. Once you know the change is legitimate, run the ssh-keygen -R command it suggests and connect again.
On Mint, /etc/ssh/ssh_config sets HashKnownHosts yes, so host names in known_hosts are stored as hashes. That hides the list of servers you visit if the file leaks. It also means you cannot grep it for a name; use ssh-keygen -F hostname instead.
Password vs key authentication¶
Password authentication sends your password (inside the encrypted channel) to the server, which checks it. It is simple, but:
- the password can be guessed, and bots try millions;
- if you ever log in to a compromised or fake server, it receives your password;
- you have to type it every time, which pushes people towards short passwords.
Public key authentication uses a key pair: a private key that never leaves your machine, and a public key that you copy to every server you want to access. The server stores authorized public keys in ~/.ssh/authorized_keys in your account there.
sequenceDiagram
participant C as Client (has private key)
participant S as Server (has public key in authorized_keys)
C->>S: I am alex, and I can prove I own key SHA256:Mg...
S->>C: That key is in authorized_keys. Prove it: sign this session data
Note over C: Sign with private key<br/>(unlocked with passphrase or agent)
C->>S: Signature
Note over S: Verify signature with public key
S->>C: Authenticated
The server never sees anything secret. It only checks a signature that could only have been made with the private key. A fake server learns nothing it could reuse. And there is nothing to guess: an ed25519 key is far beyond brute force.
The private key should be protected by a passphrase, which encrypts the key file on disk. If your laptop is stolen, the thief gets an encrypted blob, not access to your servers. The ssh-agent (below) means you type the passphrase once per login session instead of on every connection.
Key types¶
ssh-keygen can make several kinds of keys:
| Type | Recommendation |
|---|---|
| ed25519 | Use this. Small, fast, modern, secure, supported by every OpenSSH from the last decade. |
| rsa | Only for very old servers. If you must, use at least 3072 bits (the current default). |
| ecdsa | Fine, but ed25519 is preferred. |
| ed25519-sk / ecdsa-sk | Keys stored on a hardware security key (YubiKey and similar). Excellent if you have one. |
| dsa | Obsolete and disabled. Never. |
The ssh-agent¶
An ssh-agent is a small background program that holds your decrypted private keys in memory. You unlock a key once with ssh-add, and every later ssh, scp, rsync, or git call asks the agent to sign on its behalf. The key itself never leaves the agent.
On Mint's desktop, an agent is already running as part of your login session (provided by GNOME Keyring). The environment variable SSH_AUTH_SOCK points at its socket, and the first time you use a key, a dialog asks for the passphrase and can remember it for the session.
Agent forwarding (ssh -A) lets a server use your local agent for onward connections. It is convenient but risky: anyone with root on that server can use your agent while you are connected. Prefer ProxyJump (below), which needs no forwarding.
The client config file¶
Typing ssh -p 2222 -i ~/.ssh/work_key alex@203.0.113.10 every time is tedious and error-prone. The client reads defaults from ~/.ssh/config (your settings) and /etc/ssh/ssh_config (system-wide). A config file is a list of Host blocks:
Now ssh web1 does the right thing, and so do scp file web1:, rsync -a dir/ web1:dir/, and git clone web1:repo.git.
| Keyword | Meaning |
|---|---|
Host |
One or more aliases (patterns allowed, like *.example.com or *) that start a block |
HostName |
The real name or IP address to connect to |
User |
The remote username |
Port |
The remote port |
IdentityFile |
Which private key to offer |
IdentitiesOnly yes |
Offer only the listed IdentityFile, not every key in the agent |
ProxyJump |
Connect through another SSH host first (a jump host or bastion) |
ForwardAgent |
Agent forwarding (leave it off unless you need it) |
ServerAliveInterval 60 |
Send a keepalive every 60 s so idle connections are not cut by NAT routers |
AddKeysToAgent yes |
Add a key to the agent automatically the first time you use it |
LocalForward |
A permanent -L tunnel for this host |
Two rules govern how the file is read:
- For each setting, the first value found wins. ssh reads the file top to bottom, and once a keyword has a value, later blocks cannot change it.
- So put specific hosts first and general defaults (
Host *) at the end.
Jump hosts with ProxyJump¶
Production networks often expose only one hardened machine, the bastion, to the internet. Internal servers are reachable only from the bastion. ProxyJump makes this transparent:
flowchart LR
L["Laptop"] -->|"SSH to bastion"| B["bastion<br/>203.0.113.10"]
B -->|"TCP forwarded<br/>to 10.0.0.5:22"| D["db1<br/>10.0.0.5"]
L -.->|"end-to-end SSH session,<br/>encrypted laptop ↔ db1"| D
ssh db1 first connects to the bastion, asks it to open a plain TCP connection to 10.0.0.5:22, and then runs a second, independent SSH session to db1 through that pipe. Your keys stay on your laptop, and the bastion only relays encrypted bytes. On the command line, the same thing is ssh -J alex@203.0.113.10 alex@10.0.0.5.
Permissions SSH insists on¶
SSH refuses to use files that other users could have tampered with. On the server, sshd (with its default StrictModes yes) ignores your authorized_keys if your home directory, ~/.ssh, or the file itself is writable by anyone else. On the client, ssh refuses a private key that others can read.
| Path | Required permissions | Why |
|---|---|---|
~ (home) |
Not writable by group or others (e.g., 755 or 750) |
Otherwise someone could replace .ssh |
~/.ssh/ |
700 (drwx------) |
Only you may list or change it |
~/.ssh/id_ed25519 (private key) |
600 (-rw-------) |
Only you may read it |
~/.ssh/id_ed25519.pub |
644 is fine |
It is public |
~/.ssh/authorized_keys |
600 (644 also accepted) |
Must not be writable by others |
~/.ssh/config |
600 (must not be writable by others) |
The symptom of a wrong permission is that key authentication silently fails and the server falls back to asking for a password (or denies you). On the client side you get a clear warning:
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@ WARNING: UNPROTECTED PRIVATE KEY FILE! @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
Permissions 0644 for '/home/alex/.ssh/id_ed25519' are too open.
It is required that your private key files are NOT accessible by others.
This private key will be ignored.
On the server, the reason appears in the log: journalctl -u ssh shows Authentication refused: bad ownership or modes for directory /home/alex/.ssh.
The server config: sshd_config¶
The server reads /etc/ssh/sshd_config. On Ubuntu 24.04 and Mint 22, its first active line is:
That pulls in every .conf file from the drop-in directory, in alphabetical order, before the rest of the main file. Combined with sshd's rule that the first value found for a keyword wins, this has an important consequence:
- Settings in drop-in files override the main file.
- Among drop-ins, the alphabetically first file wins.
Cloud images often ship /etc/ssh/sshd_config.d/50-cloud-init.conf containing PasswordAuthentication yes. If you put your hardening in 60-hardening.conf, cloud-init's file is read first and your no is ignored. Name your file with a low number, such as 10-hardening.conf, and always verify the effective configuration with sshd -T (shown below).
The hardening settings that matter most:
| Setting | Recommended | Effect |
|---|---|---|
PasswordAuthentication |
no |
Only keys are accepted. Bots cannot guess anything. |
KbdInteractiveAuthentication |
no |
Disables the other password-prompt method (already no on Ubuntu) |
PermitRootLogin |
no |
Nobody logs in as root directly; log in as yourself and use sudo. (The default, prohibit-password, allows root with a key.) |
PubkeyAuthentication |
yes |
Key login (the default) |
AllowUsers |
alex |
Only the listed users may log in at all; everyone else is refused even with a valid key |
MaxAuthTries |
3 |
Fewer attempts per connection (default 6) |
X11Forwarding |
no |
Servers do not need to forward graphical apps |
AllowUsers takes a space-separated list and patterns, such as AllowUsers alex deploy@192.168.1.*. AllowGroups does the same for groups, which scales better: AllowGroups ssh-users.
Socket activation on Ubuntu 24.04¶
Mint 22 and Ubuntu 24.04 start sshd through socket activation: systemd listens on port 22 via ssh.socket and only launches ssh.service when the first connection arrives. This saves memory on idle machines. Two practical consequences:
- After changing authentication settings,
sudo systemctl reload ssh(orrestart ssh) applies them, as usual. - The listening port is now owned by
ssh.socket. A generator (sshd-socket-generator) readsPortandListenAddressfrom your sshd config atdaemon-reload. After changing the port, you must runsudo systemctl daemon-reloadand thensudo systemctl restart ssh.socket. Just restartingssh.servicekeeps the old port.
Copying files: scp, sftp, and rsync¶
Three tools copy files over SSH. All use the same authentication and config file, so web1: works with each.
| Tool | Model | Strengths | Weaknesses |
|---|---|---|---|
scp |
cp over SSH |
Simple, everywhere | Copies everything every time; no resume |
sftp |
Interactive FTP-like session | Browsing, many small operations, scripts with batch files | Not for syncing |
rsync |
Synchronizer | Transfers only differences, resumes, deletes, dry runs, excludes | More options to learn; must be installed on both ends |
Since OpenSSH 9.0, scp actually uses the SFTP protocol underneath; scp -O forces the old protocol for ancient servers.
rsync deserves the most attention, because it is also the backbone of the backups in the next chapter. It compares source and destination (by size and modification time, by default) and sends only what changed. Within a changed file, its delta-transfer algorithm sends only the changed blocks. Re-syncing a 10 GB folder where one file changed takes seconds.
The flags you will use constantly:
| Flag | Meaning |
|---|---|
-a |
Archive mode: recursive, and preserve permissions, timestamps, symlinks, owner, group, and devices. Equivalent to -rlptgoD. |
-v |
Verbose: list each file transferred |
-z |
Compress data in transit. Helps over slow links for text; wastes CPU on fast LANs or already compressed files. |
-h |
Human-readable sizes |
-P |
Show progress and keep partially transferred files so a retry can resume |
-n / --dry-run |
Show what would happen, change nothing |
--delete |
Delete files in the destination that no longer exist in the source, making an exact mirror |
--exclude=PATTERN |
Skip matching files (--exclude='*.tmp' --exclude=.git/) |
-e 'ssh -p 2222' |
Choose the remote shell command and its options (usually unnecessary with ~/.ssh/config) |
rsync's trailing slash, in depth¶
One character changes what rsync does: a trailing slash on the source.
rsync -a src dst/(no slash onsrc) means "copy the directory src itself into dst". You getdst/src/....rsync -a src/ dst/(slash onsrc) means "copy the contents of src into dst". You getdst/....
Here is real output from a scratch directory, with src/ containing a.txt and sub/b.txt:
sending incremental file list
created directory dst1
src/
src/a.txt
src/sub/
src/sub/b.txt
sent 250 bytes received 97 bytes 694.00 bytes/sec
total size is 4 speedup is 0.01
sending incremental file list
created directory dst2
./
a.txt
sub/
sub/b.txt
sent 240 bytes received 96 bytes 672.00 bytes/sec
total size is 4 speedup is 0.01
dst1/src/a.txt ← the directory itself was copied
dst1/src/sub/b.txt
dst2/a.txt ← only the contents were copied
dst2/sub/b.txt
Read the file list in the output: in the first case every path starts with src/. That is your early warning that you are about to get an extra level of nesting.
A trailing slash on the destination makes no difference to the result; it is just a good habit, because it makes the intent ("into this directory") clear.
The slash matters most with --delete. Combine "contents of" with --delete and the destination becomes an exact mirror of the source. Get the slash wrong and point at the wrong level, and --delete happily removes everything that "should not be there". Always do a --dry-run first:
sending incremental file list
deleting old.txt
sent 132 bytes received 31 bytes 326.00 bytes/sec
total size is 4 speedup is 0.02 (DRY RUN)
(DRY RUN) at the end confirms nothing was changed. deleting old.txt tells you exactly what --delete would remove.
Common mistake
rsync -a --delete ~/photos/ /media/alex/backup/ when you meant /media/alex/backup/photos/. With a slash on the source and --delete, rsync makes the whole backup drive look like your photos folder, deleting everything else on it. Always run with -n first and read the deleting lines.
Port forwarding (tunnels)¶
SSH can carry arbitrary TCP connections through its encrypted channel. This is port forwarding, or tunneling. There are three kinds.
Local forwarding (-L) opens a port on your machine; connections to it travel through SSH and come out on the server side, which connects to a target you name. Use it to reach a service that only the server can reach, like a database bound to 127.0.0.1.
Read -L 5432:localhost:5432 as "local port 5432 → (via web1) → localhost:5432 as seen from web1".
flowchart LR
subgraph Laptop
P["psql"] -->|"127.0.0.1:5432"| LP["ssh client<br/>listening on :5432"]
end
LP ==>|"encrypted SSH, port 22"| SD
subgraph "Server web1"
SD["sshd"] -->|"localhost:5432"| DB["PostgreSQL<br/>bound to 127.0.0.1"]
end
The target does not have to be the server itself: -L 8080:10.0.0.7:80 web1 reaches an internal web server at 10.0.0.7 through web1.
Remote forwarding (-R) is the reverse: it opens a port on the server, and connections to it travel back through SSH to a target reachable from your machine. Use it to show a teammate a development server running on your laptop, or to reach a machine behind NAT.
"Remote port 9000 on web1 → (back through the tunnel) → localhost:3000 as seen from the laptop."
flowchart RL
subgraph "Server web1"
U["curl localhost:9000"] --> RP["sshd listening on :9000"]
end
RP ==>|"encrypted SSH"| SC
subgraph Laptop
SC["ssh client"] -->|"localhost:3000"| DEV["dev server :3000"]
end
By default the remote port binds only to the server's loopback. Exposing it on the server's public interface requires GatewayPorts in the server config, which you should rarely enable.
Dynamic forwarding (-D) turns the ssh client into a SOCKS proxy. Applications configured to use it (a browser, curl --socks5-hostname) send each connection through the tunnel, and the server makes the connection on their behalf, to whatever destination they ask for. It is like a quick VPN: you browse as if you were on the server's network.
flowchart LR
subgraph Laptop
BR["Browser or curl<br/>(SOCKS proxy localhost:1080)"] --> SC["ssh client :1080"]
end
SC ==>|"encrypted SSH"| SD["sshd on web1"]
SD --> T1["intranet.example.com:80"]
SD --> T2["any other host:port<br/>the client asks for"]
Two flags make tunnels convenient: -N means "do not run a remote command, just forward", and -f sends ssh to the background after authentication. ssh -fN -L 5432:localhost:5432 web1 sets up the tunnel and returns your prompt.
fail2ban¶
Even with passwords disabled, bots keep knocking, which fills logs. fail2ban watches log files (or the journal) for repeated failures and temporarily bans the offending IP address in the firewall. It is installed with sudo apt install fail2ban, and its SSH protection is enabled by default on Ubuntu (in /etc/fail2ban/jail.d/defaults-debian.conf). Local changes go in /etc/fail2ban/jail.local, and sudo fail2ban-client status sshd shows the currently banned addresses. It is a useful extra layer, not a substitute for key-only authentication.
Commands and examples¶
Generate a key pair¶
Generating public/private ed25519 key pair.
Enter file in which to save the key (/home/alex/.ssh/id_ed25519):
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /home/alex/.ssh/id_ed25519
Your public key has been saved in /home/alex/.ssh/id_ed25519.pub
The key fingerprint is:
SHA256:MgS+3a64Uk1wgQbZkz8RzDY3AnqJo78kBMM0Sp/YALY alex@mint
The key's randomart image is:
+--[ED25519 256]--+
|o*.+o*oo |
|* O+OoO o |
|+E+=+*.= . |
|.o o ++. |
|.. .o+.S |
|.. . .+ |
|. o. . |
| o.. . . |
| ..o.. |
+----[SHA256]-----+
-t ed25519: the key type.-C "alex@mint": a comment stored in the public key, to help you recognize it later inauthorized_keys. It has no security function.- The file prompt: press Enter for the default. Use
-f ~/.ssh/work_ed25519to create a separate key for a different purpose. - The passphrase: use one. A few random words are both strong and typeable.
- The randomart is a visual version of the fingerprint. Few people use it.
-rw------- 1 alex alex 464 Oct 2 12:10 id_ed25519
-rw-r--r-- 1 alex alex 91 Oct 2 12:10 id_ed25519.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINyR...(trimmed)... alex@mint
ssh-keygen already set the correct permissions: 600 on the private key. The public key is a single line: type, base64-encoded key, comment. That line is what goes into a server's authorized_keys.
Change or add a passphrase later without changing the key:
Print the fingerprint of any key file:
Install the public key on a server¶
⚠️ VM only
Practice against your VM. You need openssh-server installed there (sudo apt install openssh-server) and your user's password for this first step.
/usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: "/home/alex/.ssh/id_ed25519.pub"
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys
alex@192.168.122.50's password:
Number of key(s) added: 1
Now try logging into the machine, with: "ssh 'alex@192.168.122.50'"
and check to make sure that only the key(s) you wanted were added.
ssh-copy-id logs in with your password one last time, appends your public key to ~/.ssh/authorized_keys on the server, and fixes the permissions of ~/.ssh there. Doing it by hand is equivalent to:
cat ~/.ssh/id_ed25519.pub | ssh alex@192.168.122.50 'mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys'
Now log in:
You are asked for the key's passphrase (or the desktop agent pops up a dialog), not the account password. That is how you know key authentication worked.
Use the agent¶
Enter passphrase for /home/alex/.ssh/id_ed25519:
Identity added: /home/alex/.ssh/id_ed25519 (alex@mint)
256 SHA256:MgS+3a64Uk1wgQbZkz8RzDY3AnqJo78kBMM0Sp/YALY alex@mint (ED25519)
ssh-add -l lists loaded keys. ssh-add -D removes them all, for example before walking away from your desk. ssh-add -t 1h key loads a key for one hour only.
If you work in a plain console or over SSH where no agent is running, start one for the current shell:
ssh-agent -s prints shell commands that set SSH_AUTH_SOCK and SSH_AGENT_PID; eval runs them in your current shell.
Debugging a connection with -v¶
When a login fails, ssh -v shows each stage from the Concepts section. Add more vs for more detail (-vvv).
OpenSSH_9.6p1 Ubuntu-3ubuntu13.19, OpenSSL 3.0.13 30 Jan 2024
debug1: Reading configuration data /home/alex/.ssh/config
debug1: /home/alex/.ssh/config line 1: Applying options for web1
debug1: Connecting to 203.0.113.10 [203.0.113.10] port 2222.
debug1: Connection established.
debug1: Remote protocol version 2.0, remote software version OpenSSH_9.6p1 Ubuntu-3ubuntu13.19
debug1: SSH2_MSG_KEXINIT sent
debug1: kex: algorithm: sntrup761x25519-sha512@openssh.com
debug1: Server host key: ssh-ed25519 SHA256:3vQm7Ck1Xp4cKdYb0W9mJzq2fT8hR5nL6sE1aV0uGiQ
debug1: Host '[203.0.113.10]:2222' is known and matches the ED25519 host key.
debug1: Authentications that can continue: publickey
debug1: Offering public key: /home/alex/.ssh/id_ed25519 ED25519 SHA256:MgS+3a64... agent
debug1: Server accepts key: /home/alex/.ssh/id_ed25519 ED25519 SHA256:MgS+3a64... agent
Authenticated to 203.0.113.10 ([203.0.113.10]:2222) using "publickey".
Map it to the stages: config applied, TCP connected, versions exchanged, key exchange (kex: algorithm, a post-quantum hybrid on OpenSSH 9.6), host key verified against known_hosts, then user authentication. Authentications that can continue: publickey tells you the server accepts only keys, so your password hardening is in effect.
Common failure points:
| You see | Meaning |
|---|---|
Connection timed out |
Firewall or routing; the TCP connection never completed |
Connection refused |
Nothing listening on that port (wrong port, or sshd not running) |
Host key verification failed |
known_hosts mismatch; see above |
Permission denied (publickey) |
The server wants a key and none of the offered keys is in authorized_keys, or permissions on the server are wrong |
Too many authentication failures |
The agent offered too many keys; use IdentitiesOnly yes with an explicit IdentityFile |
Write a client config¶
Host web1
HostName 203.0.113.10
User alex
Port 2222
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
Host db1
HostName 10.0.0.5
User alex
ProxyJump web1
Host vm
HostName 192.168.122.50
User alex
Host *
ServerAliveInterval 60
AddKeysToAgent yes
To see what ssh will actually use for a host, after all blocks are applied, ask it with -G. It prints the final configuration without connecting:
Notice port 22: the Port 2222 from the web1 block applies only to the hop to web1, not to db1. Each Host block applies only to connections to that alias.
Harden the server¶
⚠️ VM only
Run this in your throwaway VM. A mistake here can lock you out of SSH. Keep one SSH session open the whole time and test with a second one.
First, confirm key login works (you just did with ssh vm). Then write a drop-in with a low number so it wins over any other file:
sudo tee /etc/ssh/sshd_config.d/10-hardening.conf <<'EOF'
# Keys only, no root, only alex
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
AllowUsers alex
MaxAuthTries 3
X11Forwarding no
EOF
Test the syntax before applying it. sshd -t parses the whole configuration and reports errors; silence means success:
A typo produces something like:
/etc/ssh/sshd_config.d/10-hardening.conf: line 3: Bad configuration option: PermitRootLogn
/etc/ssh/sshd_config.d/10-hardening.conf: terminating, 1 bad configuration options
Then check the effective values, which accounts for every file and the first-wins rule:
sudo sshd -T | grep -Ei '^(passwordauthentication|permitrootlogin|allowusers|maxauthtries|kbdinteractive)'
maxauthtries 3
permitrootlogin no
passwordauthentication no
kbdinteractiveauthentication no
allowusers alex
Only now apply it:
ssh.service runs sshd -t again before reloading (you saw its ExecReload= lines in the systemd chapter), so a broken config never replaces a working one. Existing sessions stay connected.
Test from a new terminal on your host, keeping the old session open:
ssh vm 'echo key login works'
ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password vm
The second command forces password authentication, and the server refuses: it only offers publickey. Hardening confirmed.
Common mistake
Disabling passwords before checking that key login works, then closing the only session. If that happens in a VM, log in on its console window and fix the config. On a cloud server, you need the provider's web console. This is why the order is: key works → config → sshd -t → reload → test in a new session → only then close the old one.
Changing the SSH port (socket activation)¶
Moving SSH off port 22 does not add real security, but it does cut the log noise from bots dramatically. On Mint 22 and Ubuntu 24.04, remember the socket:
⚠️ VM only
Changing the port can lock you out. Allow the new port in the firewall first.
sudo ufw allow 2222/tcp
echo 'Port 2222' | sudo tee /etc/ssh/sshd_config.d/20-port.conf
sudo sshd -t
sudo systemctl daemon-reload
sudo systemctl restart ssh.socket
ss -tln | grep 2222
Test ssh -p 2222 vm from a new terminal, then remove the old rule with sudo ufw delete allow OpenSSH (or delete limit 22/tcp).
Watching authentication logs¶
Oct 02 12:31:04 mint sshd[3012]: Accepted publickey for alex from 192.168.122.1 port 50122 ssh2: ED25519 SHA256:MgS+3a64Uk1wgQbZkz8RzDY3AnqJo78kBMM0Sp/YALY
Oct 02 12:31:04 mint sshd[3012]: pam_unix(sshd:session): session opened for user alex(uid=1000) by alex(uid=0)
Oct 02 12:33:47 mint sshd[3100]: Connection closed by authenticating user alex 192.168.122.1 port 50140 [preauth]
Oct 02 12:35:12 mint sshd[3121]: User root from 203.0.113.77 not allowed because not listed in AllowUsers
Oct 02 12:35:12 mint sshd[3121]: Connection closed by invalid user root 203.0.113.77 port 41766 [preauth]
Accepted publickey ... SHA256:MgS+... shows exactly which key was used, which helps when you audit who has access. The same lines are in /var/log/auth.log. A quick count of failed attempts by source address:
journalctl -u ssh --since "24 hours ago" --no-pager | grep -oE 'from [0-9.]+' | sort | uniq -c | sort -rn | head
Copying with scp¶
scp report.csv web1:/tmp/ # upload a file
scp web1:/var/log/nginx/access.log . # download a file
scp -r ./site web1:/srv/ # copy a directory recursively
scp -P 2222 file alex@203.0.113.10: # explicit port: capital -P for scp!
The remote side is written host:path. A path without a leading / is relative to your remote home directory, so web1: alone means "my home directory on web1".
Common mistake
scp -p 2222 ... does not set the port. In scp, lowercase -p means "preserve modification times", and the port flag is capital -P. ssh uses lowercase -p. A config file entry with Port avoids the confusion entirely.
Interactive transfers with sftp¶
Connected to web1.
sftp> pwd
Remote working directory: /home/alex
sftp> ls
backups site notes.txt
sftp> get notes.txt
Fetching /home/alex/notes.txt to notes.txt
sftp> put report.csv
Uploading report.csv to /home/alex/report.csv
sftp> bye
get downloads, put uploads, and lcd/lls act on the local side. GUI file managers speak the same protocol: in Mint's file manager (Nemo), typing sftp://web1/home/alex in the location bar opens the remote folder.
Syncing with rsync over SSH¶
When a path has the form host:path, rsync runs over SSH automatically:
# Publish a static site: mirror local ./site/ to /srv/site/ on web1
rsync -avz --delete --dry-run ./site/ web1:/srv/site/
rsync -avz --delete ./site/ web1:/srv/site/
# Pull logs down, resuming if interrupted
rsync -avP web1:/var/log/nginx/ ./nginx-logs/
# Exclude junk
rsync -av --exclude='.git/' --exclude='*.pyc' ./project/ web1:project/
The first run copies everything; later runs send only differences and finish in seconds. rsync -v prints the files it actually sent, so a second run that prints no file names proves the two sides were already in sync.
Tunnels in practice¶
Reach PostgreSQL that only listens on web1's localhost, from your laptop:
Check that the tunnel exists, then close it:
The local end binds to 127.0.0.1, so only your laptop can use it. If you already run PostgreSQL locally, pick another local port: -L 15432:localhost:5432.
The same tunnel as a permanent config entry:
Exercises¶
Exercise 1: Your first key (easy)¶
On your main machine, create an ed25519 key with a passphrase (skip this if ~/.ssh/id_ed25519 already exists; never overwrite an existing key). Check its permissions and fingerprint, then load it into the agent and list the agent's keys.
Solution
ls ~/.ssh/id_ed25519 2>/dev/null || ssh-keygen -t ed25519 -C "alex@mint"
ls -l ~/.ssh/id_ed25519 ~/.ssh/id_ed25519.pub
ssh-keygen -lf ~/.ssh/id_ed25519.pub
ssh-add ~/.ssh/id_ed25519
ssh-add -l
The private key must show -rw------- (600). If ssh-keygen asks "Overwrite (y/n)?", answer n: overwriting a key that servers trust locks you out of them. ssh-add -l printing the same fingerprint as ssh-keygen -lf proves the agent holds that key.
Exercise 2: Key login and a config entry (easy)¶
⚠️ VM only
Use your VM as the server.
Install openssh-server in your VM. From your host, verify the VM's host key fingerprint properly (compare with ssh-keygen -lf run on the VM's console), install your key with ssh-copy-id, and add a Host vm entry so that ssh vm logs you in without a password.
Solution
In the VM:
On the host, connect once and compare the fingerprint shown in the prompt with the one from the VM before typing yes:
ssh-copy-id alex@192.168.122.50
cat >> ~/.ssh/config <<'EOF'
Host vm
HostName 192.168.122.50
User alex
EOF
chmod 600 ~/.ssh/config
ssh vm hostname
If you already have a Host * block, put the Host vm block above it, since the first value for each setting wins.
Exercise 3: rsync slash drill (medium)¶
In a scratch directory on your main machine, create photos/2026/ with three files. Predict, then verify, the resulting tree for each of: rsync -a photos backup1/, rsync -a photos/ backup2/, and rsync -a photos/2026 backup3/. Then delete one source file and show, with a dry run, what --delete would do to backup2.
Solution
mkdir -p ~/scratch/rs && cd ~/scratch/rs
mkdir -p photos/2026 && touch photos/2026/{a,b,c}.jpg
rsync -a photos backup1/
rsync -a photos/ backup2/
rsync -a photos/2026 backup3/
find backup1 backup2 backup3 -type f | sort
backup1/photos/2026/a.jpg
backup1/photos/2026/b.jpg
backup1/photos/2026/c.jpg
backup2/2026/a.jpg
backup2/2026/b.jpg
backup2/2026/c.jpg
backup3/2026/a.jpg
backup3/2026/b.jpg
backup3/2026/c.jpg
No slash copies the named directory itself (photos → backup1/photos, and 2026 → backup3/2026). A slash copies its contents.
sending incremental file list
deleting 2026/b.jpg
2026/
sent 136 bytes received 37 bytes 346.00 bytes/sec
total size is 0 speedup is 0.00 (DRY RUN)
2026/ is listed because its modification time changed when you deleted a file from it; rsync would update that timestamp too.
Exercise 4: Harden sshd safely (medium)¶
⚠️ VM only
Do this only in your VM, with a second session open.
Harden the VM's SSH server: keys only, no root login, only your user allowed, at most 3 tries. Use a drop-in file. Prove the config is valid, prove the effective values with sshd -T, reload, and prove from the host that password login is refused while key login works. Then create a second user bob with a password and show that bob is refused even with the right password.
Solution
Follow "Harden the server" above: write /etc/ssh/sshd_config.d/10-hardening.conf, then sudo sshd -t, sudo sshd -T | grep ..., sudo systemctl reload ssh. From the host:
For bob, in the VM:
From the host:
In the VM, journalctl -u ssh -n 3 shows User bob from 192.168.122.1 not allowed because not listed in AllowUsers. Even if bob had a key installed, AllowUsers refuses him. Remove bob afterwards with sudo deluser --remove-home bob.
Exercise 5: Three tunnels (hard)¶
⚠️ VM only
The VM is the server in this exercise.
(a) In the VM, start python3 -m http.server 8000 --bind 127.0.0.1. From the host, reach it through a local forward on host port 18000. (b) On the host, start python3 -m http.server 3000 --bind 127.0.0.1, and from inside the VM, reach it through a remote forward on VM port 13000. (c) Start a dynamic forward on host port 1080 and use curl through it to fetch the VM's local-only web server from (a) by the name localhost as seen from the VM. Use ss to show each listening end.
Solution
(a) Local forward, from the host:
(b) Remote forward, from the host:
python3 -m http.server 3000 --bind 127.0.0.1 &
ssh -fN -R 13000:localhost:3000 vm
ssh vm 'curl -s http://localhost:13000/ | head -3; ss -tln | grep 13000'
The listening socket for 13000 is on the VM, bound to 127.0.0.1.
(c) Dynamic forward, from the host:
With --socks5-hostname, the name localhost is resolved by the VM's sshd, so the request reaches the VM's own port 8000. Clean up with pkill -f 'ssh -fN' and stop the Python servers.
Check yourself¶
-
What does the host key protect against, and what does TOFU mean?
Answer
The host key proves the server's identity, which protects against man-in-the-middle attacks. TOFU (trust on first use) means ssh accepts and stores the key the first time you connect, then requires the same key on every later connection. Verifying the fingerprint through another channel on first use closes the remaining gap.
-
Why is key authentication safer than password authentication, even with a strong password?
Answer
Nothing secret is sent to the server: the client proves possession of the private key with a signature. A malicious or compromised server cannot capture anything reusable, and there is nothing to guess by brute force. With passwords disabled, bots cannot even try.
-
You see
WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!. What should you do?Answer
Stop and find out why. If the server was reinstalled or its IP reassigned, verify the new fingerprint through another channel, then remove the old entry with
ssh-keygen -R hostand reconnect. If there is no explanation, treat it as a possible attack and do not connect. -
Key login stopped working after you ran
chmod 777 ~on the server. Why?Answer
sshd's
StrictModesrefuses to useauthorized_keysif the home directory,~/.ssh, or the file is writable by others, because someone else could have planted a key. Fix withchmod 755 ~(or750),chmod 700 ~/.ssh,chmod 600 ~/.ssh/authorized_keys. -
You put
PasswordAuthentication noin/etc/ssh/sshd_config.d/60-hardening.conf, butsshd -Tstill showspasswordauthentication yes. What is going on?Answer
sshd uses the first value it finds for each keyword, and drop-ins are read in alphabetical order. Another file that sorts earlier, such as
50-cloud-init.conf, setsyesfirst. Rename yours to sort first (for example10-hardening.conf) or fix the other file. -
What is the difference between
rsync -a src dst/andrsync -a src/ dst/?Answer
Without the trailing slash on the source, rsync copies the directory itself, giving
dst/src/.... With the slash, it copies the contents, givingdst/.... A slash on the destination makes no difference. -
Explain
ssh -L 8080:10.0.0.7:80 web1in one sentence.Answer
It opens port 8080 on your local machine, and every connection to it is carried through the SSH connection to web1, which then connects to
10.0.0.7:80on your behalf. -
On Ubuntu 24.04, you changed
Portin the sshd config and ransystemctl restart ssh, but sshd still listens on 22. Why?Answer
SSH is socket-activated:
ssh.socketowns the listening port. Runsudo systemctl daemon-reload(so the generator rereads the port) andsudo systemctl restart ssh.socket.
Key takeaways¶
- SSH sets up a connection in stages: key exchange (encryption), host key check (server identity, via
known_hostsand TOFU), user authentication, then channels. - Use ed25519 keys with a passphrase,
ssh-copy-idto install them, and the agent so you type the passphrase once. ~/.ssh/configturns long commands intossh web1; the first value for each setting wins, so putHost *last.ssh -G hostshows the result.ProxyJumpreaches internal hosts through a bastion.- SSH demands strict permissions:
~/.ssh700, private keys andauthorized_keys600, home not writable by others. - Harden with a low-numbered drop-in (
PasswordAuthentication no,PermitRootLogin no,AllowUsers), check withsshd -tandsshd -T, reload, and test in a new session before closing the old one. - Prefer
rsync -avzfor copying; the source's trailing slash means "contents of". Always--dry-runbefore--delete. -Lbrings a remote service to you,-Rexposes your local service on the server,-Dmakes a SOCKS proxy.
Next¶
You can now reach, secure, and copy data to your servers. Next, look after the data itself: disks, partitions, filesystems, mounting, and a backup strategy that survives a real failure. Continue with Disks and backups.