SSH: Secure Shell
- SSH: Secure Shell
- More than a remote login
- Keys: generate, distribute, use
- Host keys and
known_hosts: authenticating the server - The client config file:
~/.ssh/config - Reusing one connection: multiplexing
- Copying files over SSH: scp, sftp, and rsync
- Tunnels: forwarding ports over SSH
authorized_keys: controlling what a key can do- Certificates: keys that expire and scale
- Where options come from: the precedence rules
- When it doesn’t work: debugging
- Key takeaways
- References
More than a remote login
You will spend this entire course inside ssh — connecting to the course VM, the lab machines, and ada. Most people learn just enough to type ssh user@host and stop there, and in doing so miss that SSH is one of the most capable tools on the system: an encrypted transport that can carry interactive shells, file transfers, arbitrary TCP connections (tunnels), X11, and agent credentials, all multiplexed over a single authenticated connection.
SSH matters for security on both sides of the fence. Defensively, key-based authentication is the single biggest upgrade you can make over passwords (no password to phish or brute-force), and ~/.ssh/authorized_keys is a file defenders must watch because adding a key is the quietest persistence an attacker can establish. Offensively, SSH tunnels are the classic pivoting mechanism for lateral movement — a -D SOCKS proxy turns one foothold into access to an entire internal network.
This page covers key generation and use, the ~/.ssh/config file (patterns, placeholders, and Match), forward/reverse/dynamic tunnels, locking down authorized_keys, and the often-confusing question of which setting wins when options are specified in several places at once.
ℹ️ SSH has three layers: a transport layer that negotiates encryption and authenticates the server (via its host key), a user-authentication layer (password, public key, etc.), and a connection layer that multiplexes channels (your shell, tunnels, agent forwarding) over the one encrypted pipe. The host key is why you see “The authenticity of host … can’t be established” on first connect — that is trust-on-first-use, recorded in
~/.ssh/known_hosts.
Keys: generate, distribute, use
Public-key authentication replaces a password with a key pair: a private key that never leaves your machine and a public key you place on every server you want to reach. The server challenges you to prove you hold the private key; nothing reusable crosses the wire. This is the digital-signature / asymmetric-crypto primitive applied to login.
Generating a key
Use Ed25519 — it is fast, small, and modern (prefer it over RSA; if you must use RSA, use 4096-bit):
$ ssh-keygen -t ed25519 -C "dmcgrath@laptop-2026"
Generating public/private ed25519 key pair.
Enter file in which to save the key (~/.ssh/id_ed25519):
Enter passphrase (empty for no passphrase): ← USE a passphrase
...
Your identification has been saved in ~/.ssh/id_ed25519 (private — guard this)
Your public key has been saved in ~/.ssh/id_ed25519.pub (shareable)
-t ed25519— the key type.-Cis just a comment to help you identify the key later.- Always set a passphrase. It encrypts the private key at rest, so a stolen laptop ≠ a stolen key. The
ssh-agent(below) means you only type it once per session. - You can have many keys for different purposes — separate keys per service is good hygiene (compromise of one doesn’t expose the others). The example config later uses distinct keys for GitHub, GitLab, and Codeberg.
Hardware-backed keys (FIDO/security keys)
A passphrase protects a private key at rest, but the key is still a file that malware running as you can copy. A security key (YubiKey, SoLoKey, or a platform authenticator) removes that: the secret lives in the token’s hardware and cannot be exported, so an attacker with full access to your laptop still cannot take it with them. OpenSSH has supported these natively since 8.2:
$ ssh-keygen -t ed25519-sk -C "dmcgrath@yubikey"
Generating public/private ed25519-sk key pair.
You may need to touch your authenticator to authorize key generation.
Enter file in which to save the key (~/.ssh/id_ed25519_sk):
The -sk types produce a small key handle on disk plus the real secret in the token; the file alone is useless without the hardware. Authentication requires a physical touch by default, which also stops silent use by anything running on your machine. Useful variants:
$ ssh-keygen -t ed25519-sk -O resident # store on the token; recover with ssh-keygen -K
$ ssh-keygen -t ed25519-sk -O verify-required # also require the token's PIN or biometric
$ ssh-keygen -t ecdsa-sk # for tokens too old for Ed25519
On the server side, verify-required is also an authorized_keys option, so the server can insist a key was used with PIN/biometric verification rather than trusting the client to have asked.
ℹ️ Both ends need OpenSSH 8.2 or newer, and the server must accept the
skkey types — so check before you rely on one for your only route into a machine. Keep a second key (and a second token, or a passphrase-protected software key kept offline) enrolled, or you can lock yourself out of everything at once.
Distributing the public key
The public key must end up in ~/.ssh/authorized_keys on the server. The painless way:
$ ssh-copy-id -i ~/.ssh/id_ed25519.pub user@server
Without ssh-copy-id, append it manually (note the append >> so you don’t clobber existing keys):
$ cat ~/.ssh/id_ed25519.pub | ssh user@server 'cat >> ~/.ssh/authorized_keys'
⚠️ Permissions are enforced by
sshd. SSH refuses to use keys if the permissions are too open:~/.sshmust be700, and~/.ssh/authorized_keysand your private key600. A silently failing key-auth is, nine times out of ten, a permissions problem (chmod 700 ~/.ssh; chmod 600 ~/.ssh/*).
The agent: type the passphrase once
ssh-agent holds your decrypted keys in memory so you aren’t prompted on every connection:
$ eval "$(ssh-agent -s)" # start an agent (usually already running)
$ ssh-add ~/.ssh/id_ed25519 # add a key (prompts for the passphrase once)
$ ssh-add -l # list loaded keys
$ ssh-add --apple-use-keychain ~/.ssh/id_ed25519 # macOS: store passphrase in Keychain
The config option AddKeysToAgent yes (used in the example config below) does this automatically the first time a key is used.
Windows: enable the ssh-agent service
Windows ships OpenSSH (since Windows 10 1809 / Server 2019), but unlike Linux and macOS its ssh-agent is a Windows service that is disabled by default — so ssh-add fails with “Error connecting to agent” until you turn it on. Enable it once, from an administrator PowerShell prompt:
# Run as Administrator. Set the agent to start automatically on boot, then start it now.
Get-Service ssh-agent | Set-Service -StartupType Automatic
Start-Service ssh-agent
Get-Service ssh-agent # should report Status: Running
After that one-time setup, use ssh-add from a normal (non-elevated) prompt exactly as on Linux — note the $env:USERPROFILE path style instead of ~:
ssh-add $env:USERPROFILE\.ssh\id_ed25519 # prompts for the passphrase once
ssh-add -l # list loaded keys
The Windows agent stores keys in your Windows account’s security context (backed by the registry, in DPAPI-protected form), so they persist across reboots and the service reloads them automatically — you are not prompted again.
ℹ️ This applies to the native Windows OpenSSH in PowerShell/Command Prompt. If you instead use Git Bash, it runs its own MSYS2
ssh-agent(start it witheval "$(ssh-agent -s)", as on Linux), and WSL is a separate Linux environment with its own agent again — three different agents that do not share keys. Pick one workflow per machine to avoid confusion.
⚠️ Microsoft’s docs suggest backing up the private key elsewhere and deleting it from disk once it is loaded into the agent, since the agent can serve it without the file present. Be careful: if the agent’s stored copy is ever lost (profile reset, re-image) and you kept no backup, the key is gone — you would have to generate a new pair and re-distribute the public key everywhere.
⚠️ “Error connecting to agent: No such file or directory” — even though the service is
Running. The native Windows client reaches the agent over a named pipe (\\.\pipe\openssh-ssh-agent) and ignores the Unix-styleSSH_AUTH_SOCK. But if something has setSSH_AUTH_SOCK, the client tries that socket path instead, and since it does not exist as a Windows file you get this error. Diagnose and fix in PowerShell:$env:SSH_AUTH_SOCK # if this prints a path, that's the problem Remove-Item Env:\SSH_AUTH_SOCK # clear it for this session ssh-add -l # should now connect (lists keys, or "no identities")The usual culprit is a terminal that forwards its own agent. WezTerm is the common one: its
mux_enable_ssh_agentoption (on by default) setsSSH_AUTH_SOCKin every pane to a WezTerm-managed socket — fine on Linux/macOS, but it shadows the Windows named pipe. Disable it in~/.wezterm.luawithconfig.mux_enable_ssh_agent = false, then fully restart the terminal (existing panes keep the old environment). WSL↔Windows agent-sharing tools (npiperelay/wsl-ssh-agent) and some dotfiles setSSH_AUTH_SOCKfor the same reason — check those if it reappears in a fresh shell.
⚠️ Agent forwarding (
ForwardAgent) is convenient and dangerous. It lets a remote host use your local keys to authenticate onward — but root on that host can hijack your agent and impersonate you everywhere. Leave itnoby default and enable it per-host only when you trust the box. (Note the example config setsForwardAgent no.) PreferProxyJumpfor reaching machines through a bastion.
Everyday ssh-keygen operations
Beyond generating keys, ssh-keygen answers most “what is this key?” questions:
$ ssh-keygen -lf ~/.ssh/id_ed25519.pub # fingerprint (-l) of a key file (-f)
256 SHA256:HDwq/kt0GCL7Q5rQ8ZY... dmcgrath@laptop-2026 (ED25519)
$ ssh-keygen -lvf ~/.ssh/id_ed25519.pub # add -v for the ASCII-art randomart
$ ssh-keygen -y -f ~/.ssh/id_ed25519 # re-derive the PUBLIC key from a private key
$ ssh-keygen -p -f ~/.ssh/id_ed25519 # change (or add) the passphrase, keeping the key
$ ssh-keygen -c -f ~/.ssh/id_ed25519 # change the comment
-y is the one worth remembering: a lost .pub file is not a lost key pair, because the public key can always be recomputed from the private one. A lost private key is unrecoverable — generate a new pair and redistribute.
Host keys and known_hosts: authenticating the server
Everything above authenticates you to the server. The host key runs the other way — it is how you know the machine answering on port 22 is the one you meant. Without it, every key you offer and every keystroke you type could be going to somebody in the middle.
The server holds a host key pair in /etc/ssh/ssh_host_*_key. On first connect you are shown its fingerprint and asked to accept it; that is trust on first use (TOFU), and the answer is recorded in ~/.ssh/known_hosts. Every later connection checks the presented key against that record.
$ ssh ada
The authenticity of host 'ada (131.252.208.30)' can't be established.
ED25519 key fingerprint is SHA256:R0m1aSbWMUDD0Fw8zvBzMPmm1eXrqI8/D5RBbFWFK5w.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
The honest answer to that prompt is “I don’t know yet.” To actually verify it, get the fingerprint out of band — from the machine’s console, or from whoever built it:
# On the server (or its console), print the fingerprint to compare:
$ ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
256 SHA256:R0m1aSbWMUDD0Fw8zvBzMPmm1eXrqI8/D5RBbFWFK5w root@ada (ED25519)
Note the third option at that prompt: you can paste the expected SHA256:… fingerprint instead of typing yes, and SSH accepts the connection only if it matches.
When the host key changes
This is the warning every student meets sooner or later, usually after a VM is rebuilt:
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
...
Offending ECDSA key in /home/you/.ssh/known_hosts:42
It means exactly what it says: the key does not match the one you recorded. There are two possible explanations, and they look identical from here —
- Benign: the host was reinstalled, rebuilt, or restored, so it generated a new host key. In a lab that reimages VMs, this is the common case.
- Hostile: something is intercepting the connection — the scenario TOFU exists to catch.
Do not reflexively delete the line. Confirm out of band that the host really was rebuilt; then remove the stale entry:
$ ssh-keygen -F ada # show the known_hosts entry for a host
$ ssh-keygen -R ada # remove it (rewrites known_hosts, keeps a .old backup)
$ ssh ada # reconnect and verify the NEW fingerprint
⚠️ The advice you will find on the internet —
StrictHostKeyChecking no, or pipingssh-keyscanstraight intoknown_hosts— silences the warning by turning off server authentication. That converts SSH’s strongest guarantee into decoration. If you need automation to connect without a prompt, useaccept-new, which trusts a first sighting but still refuses a changed key:Host lab-* StrictHostKeyChecking accept-newThe values are
yes(never add automatically, refuse changes),accept-new(add new, refuse changes),ask(the default — prompt for new, refuse changes), andno/off(add new and accept changes: the dangerous one).
Pre-seeding and scaling known_hosts
ssh-keyscan collects host keys over the network. It is fine for bootstrapping a list you then verify, and not a substitute for verification:
$ ssh-keyscan -t ed25519 ada >> ~/.ssh/known_hosts # fetch and append
$ ssh-keyscan -t ed25519 ada | ssh-keygen -lf - # print fingerprints to compare
Two more things you will run into:
- Hashed entries. If
known_hostsis full of|1|...|...=lines instead of hostnames,HashKnownHosts yesis on (the default on Debian and its derivatives). It hides which hosts you connect to from anyone who reads the file — an attacker on a compromised machine can’t harvest your list of targets.ssh-keygen -Fand-Rboth work on hashed files; grep does not. - Reused addresses. When many machines share an IP, use
HostKeyAlias(covered in the config section below) so their keys are filed separately instead of colliding.
The client config file: ~/.ssh/config
Typing -i, -p, -l, and ProxyJump flags every time is miserable. ~/.ssh/config lets you name hosts and attach settings to them. This is where SSH gets powerful, so the examples below are drawn from a real working config.
Host blocks, patterns, and placeholders
A Host line introduces a block whose options apply to any connection whose target name matches the pattern. Patterns use glob wildcards: * (any run of characters), ? (one character), and ! (negation).
# A single named host with its own key
Host ada
Hostname linux.cs.pdx.edu
IdentityFile ~/.ssh/id_ed25519
# '??' matches exactly two characters: systemsec-01 ... systemsec-99
Host systemsec-??
Hostname %h.cs.pdx.edu # %h = the host you typed
Port 22
IdentitiesOnly yes # only offer the key below, not every agent key
IdentityFile ~/.ssh/lab/proxmox/%n # %n = the original name on the command line
User root
The % tokens are placeholders expanded at connect time — they let one block serve many hosts:
| Token | Expands to |
|---|---|
%h |
the host name being connected to (after Hostname substitution) |
%n |
the original name you typed on the command line |
%p |
the port |
%r |
the remote username |
%% |
a literal % |
So ssh systemsec-07 connects to systemsec-07.cs.pdx.edu as root using the key ~/.ssh/lab/proxmox/systemsec-07 — all from one block.
⚠️ Not every token works in every directive, which is a quiet source of “why didn’t that expand?”.
Hostnameaccepts only%%and%h—Hostname %n.cs.pdx.eduwill not work.ProxyCommandandProxyJumpaccept%%,%h,%n,%p, and%r. The full set above is accepted byIdentityFile,ControlPath,CertificateFile,IdentityAgent,Include,LocalForward,RemoteForward,UserKnownHostsFile, andMatch exec.LocalCommandaccepts all of them. The authoritative list is the TOKENS section ofssh_config(5).
Wildcard groups and ProxyJump
ProxyJump (-J on the command line) routes a connection through a bastion/jump host — the traffic to the final host is tunneled inside the connection to the jump host, so the final host never needs to be directly reachable. Combine it with a wildcard group:
# Every host starting 'pp' is reached by jumping through the 'pandaprox' gateway
Host pp*
ProxyJump pandaprox
IdentityFile ~/.ssh/pandaprox
Host ppkali
Hostname 10.20.100.105
HostKeyAlias ppkali # see note below
User kali
Now ssh ppkali transparently hops you → pandaprox → ppkali. You can chain jumps too: ProxyJump ada,imcgrath goes through two bastions in order (equivalent to ssh -J ada,imcgrath …).
ℹ️
HostKeyAliasis the fix for a real lab headache: when several different VMs reuse the same internal IP (e.g. each student rack hands out10.20.100.105), their host keys collide inknown_hostsand SSH screams about a possible attack.HostKeyAlias ppkalitells SSH to file this host’s key under the nameppkaliinstead of the shared IP, keeping them separate.
The Match keyword: conditional configuration
Host only matches on the target name. Match matches on richer conditions — and crucially can run a command and branch on its exit status with exec. This config uses it to only jump through a bastion when off-campus:
# If we are NOT already on the cecs network (resolv.conf has no cecs search
# domain), reach 'panda' by proxying through 'ada'. On campus, connect directly.
Match host panda !exec "grep -q 'search.*cecs.pdx.edu' /etc/resolv.conf"
ProxyJump ada
Match host panda restricts it to the panda target; !exec "..." adds the condition “and the command fails” (the ! negates) — i.e. we are not on campus. When both hold, the ProxyJump applies; otherwise it’s skipped and you connect directly. This is how one config Just Works from the coffee shop and from the lab without edits.
The full criteria list is canonical, final, exec, localnetwork, host, originalhost, tagged, command, user, localuser, and version; any of them can be negated with !, and all matches everything.
ℹ️
Match localnetwork(OpenSSH 9.4+) does the same job without shelling out. It matches on the addresses actually assigned to your interfaces, which is both faster and more robust than grepping/etc/resolv.conf—execruns a subprocess on every config parse, and theresolv.conftrick breaks the moment a VPN orsystemd-resolvedrewrites the file. On a new enough client, prefer:Match host panda !localnetwork 131.252.0.0/16 ProxyJump adaKeep the
execform if you need to support clients older than 9.4 — Ubuntu 22.04 ships 8.9, for instance.
The catch-all Host * and why it goes last
A block matching Host * applies to everything. It is the right place for global defaults — but it must go at the bottom of the file, for the precedence reason in the next section:
Host *
IdentitiesOnly yes # only use specified/loaded keys, not every key (avoids "too many auth failures")
AddKeysToAgent yes # auto-add keys to the agent on first use
IgnoreUnknown UseKeychain # don't error on the next line where UseKeychain is unsupported (Windows)
UseKeychain yes # macOS: pull passphrases from the Keychain
ForwardAgent no # safe default (see agent-forwarding warning above)
ServerAliveInterval 30 # send a keepalive every 30s …
ServerAliveCountMax 3 # … and drop after 3 missed, so dead connections don't hang
User dmcgrath # default username almost everywhere
Splitting the config with Include
Once a config grows past a screen or two — or once some of it is work-specific, machine-specific, or not yours to commit — split it:
# At the TOP of ~/.ssh/config, because first-match-wins (see the precedence
# section): whatever the included files set takes effect before anything below.
Include ~/.ssh/config.d/*.conf
Each fragment is an ordinary config file. This is how you keep a lab config that changes every term separate from the personal settings that don’t, and how tools that manage SSH access (cloud CLIs, gh, corporate device management) add host blocks without editing your file. Relative paths are taken as relative to ~/.ssh.
Reusing one connection: multiplexing
Every new SSH connection pays for a TCP handshake, a key exchange, and an authentication round trip — noticeable when a script runs ssh host in a loop, or when you open a second window to a host across a slow link. Connection multiplexing makes later connections ride the channel layer of the first one, which costs nothing.
Host *
ControlMaster auto # reuse an existing connection, or become the master
ControlPath ~/.ssh/cm/%C # one socket per connection; %C is a hash of host/port/user
ControlPersist 10m # keep the master alive 10 minutes after the last session
Create the directory first (mkdir -p ~/.ssh/cm), since SSH will not make it for you. %C is used rather than %h:%p:%r because the literal form produces long socket paths that can exceed the ~104-character Unix socket limit.
The difference is easy to see:
$ time ssh ada true # first connection: full handshake + auth
real 0m1.284s
$ time ssh ada true # second: rides the existing master
real 0m0.071s
Manage the master with -O:
$ ssh -O check ada # is a master running?
Master running (pid=48213)
$ ssh -O exit ada # tear it down (drops every session using it)
⚠️ Two caveats. Multiplexed sessions share the master’s fate — kill the master and every session over it dies, and a master established with agent forwarding shares that too. And the socket in
ControlPathis a live, already-authenticated channel to the remote host: anyone who can read it gets in without a key, so it belongs under your own700directory and never on shared storage. Some servers also disable it (MaxSessions 1).
Copying files over SSH: scp, sftp, and rsync
The same authenticated channel that carries your shell can carry files. Three tools ride on top of SSH, and they share its keys, its ~/.ssh/config host aliases, and its authorized_keys restrictions.
scp — quick file copy
scp copies files between hosts with cp-like syntax, where a remote path is host:path (the host can be any alias from your config):
$ scp report.pdf ada:~/uploads/ # local → remote (uses the 'ada' config block)
$ scp ada:/etc/motd ./ # remote → local
$ scp -r ./site ada:/var/www/ # -r: copy a directory recursively
$ scp -P 2222 file user@host:~/ # -P (CAPITAL) sets the port — NOT -p like ssh!
$ scp -i ~/.ssh/other_key file host:~/ # -i: pick an identity, same as ssh
$ scp -C bigfile.tar ada:~/ # -C: compress in transit
⚠️ Two
scpgotchas. The port flag is-P(capital) — lowercase-pmeans “preserve timestamps,” the opposite ofssh’s-p. And as of OpenSSH 9.0 (2022),scpuses the SFTP protocol under the hood by default; its old RCP-based protocol had surprising filename-glob and quoting behavior and is now deprecated (-Oforces the legacy mode). For anything beyond a one-off copy, prefersftporrsync.
sftp — interactive or scripted transfer
sftp speaks a richer file-transfer protocol over the same SSH connection, with an FTP-like interactive prompt (get, put, ls, cd, mkdir) or batch mode:
$ sftp ada
sftp> put localfile.txt # upload
sftp> get remotefile.txt # download
sftp> ls -l # browse the remote side
sftp> bye
$ sftp -b commands.txt ada # -b: run a batch script non-interactively
rsync — the right tool for big or repeated copies
For large trees, mirrors, or anything you’ll copy more than once, rsync over SSH transfers only the changed portions and preserves attributes — far more efficient than scp. It’s covered on the Working with Files page; in brief:
$ rsync -avz --progress ./build/ ada:/var/www/html/ # sync over SSH (rsync uses ssh by default)
$ rsync -avz -e "ssh -p 2222" src/ host:dst/ # -e to pass custom ssh options
Because all three run over SSH, they obey the server’s policy: a key locked to a command="…" forced command (below) won’t run scp unless your wrapper allows it, and the sftp subsystem can be disabled or chrooted in sshd_config to offer file transfer without shell access.
Tunnels: forwarding ports over SSH
Because the SSH connection layer multiplexes channels, you can push arbitrary TCP connections through the encrypted pipe. There are three directions, and the trick to keeping them straight is to ask which machine opens the listening port.
The common flags: -N (do not run a remote command — just hold the tunnel), -f (background after auth), and -T (no terminal).
Local forwarding (-L) — pull a remote service to you
-L [bind:]localport:target:targetport opens a listener on your machine; connections to it pop out from the SSH server and go to target:targetport. Use it to reach something only the server can see — a database bound to localhost, an internal web UI, a VPN-less path to an internal host.
# Reach a database that only listens on the server's localhost:5432.
# Now connecting to localhost:5432 on YOUR box hits the remote Postgres.
$ ssh -N -L 5432:localhost:5432 ada
# Reach an internal-only web app (intranet.internal:80) via the bastion 'ada':
$ ssh -N -L 8080:intranet.internal:80 ada # browse http://localhost:8080
Remote forwarding (-R) — expose your service to the far side
-R [bind:]remoteport:target:targetport opens a listener on the SSH server; connections there are tunneled back and emerge from your machine toward target:targetport. Use it to expose a local service to a network you can only reach outbound — the basis of the Windows-RDP-over-SSH guide.
# Expose your laptop's RDP (localhost:3389) as port 1222 on the server:
$ ssh -N -R 1222:localhost:3389 ada
⚠️ Reverse forwarding is also how attackers establish reverse shells and exfiltration paths that beat egress firewalls — an outbound SSH session that opens an inbound door. Seeing unexpected
-Rtunnels on a host is a red flag for SOC/IR. By default the server-side listener binds only to its loopback;GatewayPortsmust be enabled to expose it more widely.
Dynamic forwarding (-D) — a SOCKS proxy / poor man’s VPN
-D localport opens a SOCKS proxy on your machine; any app pointed at it has its traffic emerge from the SSH server, to any destination. One command turns a single SSH foothold into network-wide access — which is exactly why it’s a favorite pivoting tool.
$ ssh -N -D 1080 ada # SOCKS5 proxy on localhost:1080
$ curl --socks5-hostname localhost:1080 http://internal-only.host/ # routed through ada
You can make a tunnel permanent in ~/.ssh/config with LocalForward, RemoteForward, and DynamicForward directives attached to a host.
authorized_keys: controlling what a key can do
On the server, ~/.ssh/authorized_keys lists the public keys allowed to log in as that user. The basic format is one key per line. But each line can be prefixed with options that constrain what the key may do — turning a key from “full shell access” into a narrowly scoped capability.
Forced commands: a key that can only do one thing
The command="…" option forces that command to run on login, ignoring whatever the client asked for. This is the standard way to grant automated, least-privilege access — a backup job, a git pull, a status check — without handing over a shell:
# In the server's ~/.ssh/authorized_keys:
command="/usr/local/bin/backup-readonly.sh",restrict ssh-ed25519 AAAA...key... backup@ci
# 'restrict' (OpenSSH 7.2+) denies EVERYTHING — no port forwarding, no agent
# forwarding, no X11, no pty — the safest baseline. Opt back in explicitly if needed,
# e.g.: restrict,pty or restrict,port-forwarding
The command the client tried to run is available to your script in $SSH_ORIGINAL_COMMAND, so a single forced command can dispatch among a few allowed actions:
#!/bin/bash
# backup-readonly.sh — only permit specific rsync/backup invocations
case "$SSH_ORIGINAL_COMMAND" in
"rsync --server --sender"*) exec $SSH_ORIGINAL_COMMAND ;;
*) echo "Denied: this key may only run backups." >&2; exit 1 ;;
esac
Other useful per-key restrictions
| Option | Effect |
|---|---|
restrict |
Deny all features (the recommended baseline); re-enable selectively |
from="198.51.100.0/24,*.cs.pdx.edu" |
Only accept this key from matching source addresses/hosts |
no-port-forwarding |
This key cannot set up -L/-R/-D tunnels |
no-agent-forwarding, no-X11-forwarding, no-pty |
Disable those specific features |
permitopen="host:port" |
Restrict -L/-D forwarding to a single destination |
permitlisten="host:port" |
Restrict -R forwarding to a single listen address (OpenSSH 7.8+) |
expiry-time="20261231" |
Key stops working after a date (OpenSSH 7.7+; append Z for UTC, 9.1+) |
⚠️ Because
authorized_keysgrants standing access, it is a top persistence mechanism. Auditing it — and watching it with file-integrity monitoring (auditd/AIDE) — is basic host hardening. An unexplained key, or one with nofrom=/restrict, deserves investigation.
Certificates: keys that expire and scale
authorized_keys works until you have many users and many servers, at which point it becomes N × M files to edit — and, crucially, there is no way to make a key stop working everywhere at once. Removing someone’s access means visiting every host. SSH certificates solve both problems, and they are the mechanism behind every large SSH deployment you will meet in industry.
A certificate is a public key plus metadata — who it belongs to, which principals it may log in as, and when it expires — signed by a certificate authority (CA), which is just another SSH key kept somewhere safe. A host that trusts the CA accepts any unexpired certificate it signed, without knowing the individual key.
# 1. Create a CA key (once; guard the private half like a crown jewel)
$ ssh-keygen -t ed25519 -f ~/ca/user_ca -C "CS dept user CA"
# 2. Sign a user's public key: -I is an identifier that shows up in logs,
# -n the principals (usernames) it may log in as, -V the validity window
$ ssh-keygen -s ~/ca/user_ca -I "jsmith@pdx" -n jsmith,devuser -V +8h \
~/keys/jsmith_ed25519.pub
Signed user key ~/keys/jsmith_ed25519-cert.pub: id "jsmith@pdx" serial 0 for jsmith,devuser
valid from 2026-09-22T09:00:00 to 2026-09-22T17:00:00
# 3. Inspect any certificate
$ ssh-keygen -Lf ~/keys/jsmith_ed25519-cert.pub
The server trusts the CA with one line in sshd_config, and then needs no per-user key at all:
# /etc/ssh/sshd_config
TrustedUserCAKeys /etc/ssh/user_ca.pub
The same idea runs in the other direction for host keys, and it retires the TOFU problem entirely. Sign each host’s key with a host CA, and put one line in known_hosts:
# In ~/.ssh/known_hosts — trust any host presenting a cert signed by this CA
@cert-authority *.cs.pdx.edu ssh-ed25519 AAAA...host-CA-public-key...
Now a freshly rebuilt VM presents a certificate signed by the CA, and it just works — no “REMOTE HOST IDENTIFICATION HAS CHANGED”, no prompt, and no habit of clicking through warnings.
Two authorized_keys options relate to this: cert-authority marks a line as a CA to trust rather than a key to accept, and principals="..." limits which certificate principals that CA may authorise.
ℹ️ The real win is expiry. An 8-hour certificate means offboarding is automatic: stop issuing, and access ends on its own. Compare that with
authorized_keys, where a forgotten line grants access forever. Revoking before expiry needs a revocation list (ssh-keygen -k, pointed at byRevokedKeys), which is why short lifetimes are preferred over long ones plus revocation.
Where options come from: the precedence rules
When the same option can be set on the command line, in your user config, in the system config, and (server-side) in sshd_config, which wins? Two different rule-sets are at play — get them straight and the rest of SSH stops surprising you.
Client side — “first value wins”
For the client, options are gathered in this order, and for each option the first value obtained is used and later ones are ignored:
- Command-line
-o,-i,-p,-l, etc. (highest priority — read first) ~/.ssh/config(your per-user file)/etc/ssh/ssh_config(system-wide defaults, lowest priority)
Two consequences trip everyone up at least once:
- Command-line options win over the config, because they’re read first — handy for one-off overrides:
ssh -o ForwardAgent=yes somehost. - Within the config, the earliest matching line wins. That is why specific
Hostblocks go at the top and the catch-allHost *goes at the bottom: ifHost *came first, itsUser dmcgrathwould lock in before a specific block could override it. Order matters.
# One-off override: force a different key and user for a single connection,
# beating whatever ~/.ssh/config says, without editing any file:
$ ssh -i ~/.ssh/other_key -o User=root -o IdentitiesOnly=yes systemsec-07
# Inspect exactly which options SSH resolved for a host (no connection made):
$ ssh -G systemsec-07 | grep -iE 'user|identityfile|proxyjump|hostname'
ssh -G <host> prints the fully-resolved configuration SSH would use — the definitive way to debug “why is it picking that key/user?”
Server side — sshd_config and Match
The server independently decides what it will allow, in /etc/ssh/sshd_config. Here the precedence is different: the first matching Match block wins, and a Match overrides the global settings for connections that match it. This is how an admin says “passwords are off everywhere, but this one bastion account may only port-forward”:
# /etc/ssh/sshd_config (server)
PasswordAuthentication no # global: keys only
KbdInteractiveAuthentication no # close the other password-shaped door (PAM)
PermitRootLogin prohibit-password # root may log in by key, never by password
AllowGroups sshusers # only members of this group may log in at all
MaxAuthTries 3 # disconnect after 3 failed attempts
LoginGraceTime 30 # 30s to authenticate before the connection is dropped
X11Forwarding no # off unless a lab actually needs it
AllowTcpForwarding no # no -L/-R/-D from this server (see the tunnels section)
Match User backup # for the 'backup' user only:
ForceCommand /usr/local/bin/backup-readonly.sh
PermitTTY no
AllowGroups/AllowUsers are the highest-value line there: they turn SSH from “every account on the box” into an explicit allowlist, so a service account created by some package cannot be logged into even if it somehow acquires a password. Note PasswordAuthentication no alone is not enough — KbdInteractiveAuthentication (called ChallengeResponseAuthentication before OpenSSH 8.7) can still let PAM prompt for a password.
The client config requests; the server config disposes. If the server says PasswordAuthentication no, no amount of client configuration brings it back. When something is refused, check both sides — and sshd -T on the server prints its fully-resolved effective config, the server-side analogue of ssh -G.
When it doesn’t work: debugging
SSH fails quietly by design — a server that explained why it rejected you would be helping an attacker. So the information you need is on the client, behind -v, and on the server, in its logs.
Client side: -v, -vv, -vvv
Each v adds a level. One is usually enough to see which keys were offered and in what order:
$ ssh -v ada
debug1: Reading configuration data /home/you/.ssh/config
debug1: /home/you/.ssh/config line 12: Applying options for ada
debug1: Connecting to linux.cs.pdx.edu [131.252.208.30] port 22.
debug1: Server host key: ssh-ed25519 SHA256:R0m1aSbWMUDD0Fw8...
debug1: Offering public key: /home/you/.ssh/id_ed25519 ED25519
debug1: Server accepts key: /home/you/.ssh/id_ed25519 ED25519
debug1: Authentication succeeded (publickey).
Read it for three things: which config blocks applied (Applying options for … — this catches a Host * that matched sooner than you meant), which keys were offered in which order, and where it stopped. Offering public key with no Server accepts key after it means the server did not find that key in authorized_keys.
The most common failures and what they look like:
| Symptom | Usual cause |
|---|---|
Permission denied (publickey) after offering the right key |
Key not in the server’s authorized_keys, or ~/.ssh / authorized_keys permissions too open (server-side StrictModes) |
Too many authentication failures |
The agent offered every loaded key and hit MaxAuthTries before the right one; fix with IdentitiesOnly yes + an explicit IdentityFile |
Offers the wrong key, ignores IdentityFile |
Another block matched first — check ssh -G and the Applying options for lines |
Hangs after Connecting to … |
Filtered port or wrong host, not an auth problem |
no matching host key type found |
Legacy server — see below |
Server side
The client is only ever told “no”. The reason is in the server’s log:
$ sudo journalctl -u ssh -f # Debian/Kali/Ubuntu (the unit is 'ssh')
$ sudo journalctl -u sshd -f # Arch, Fedora, RHEL
$ sudo tail -f /var/log/auth.log # systems still logging to files
Authentication refused: bad ownership or modes for directory /home/you/.ssh is the permissions problem from earlier, stated plainly — and it appears only in the server log, which is why key auth that “silently fails” is so often diagnosed in the wrong place.
Before restarting sshd after an edit, check the config — a syntax error plus a restart can lock you out of a remote machine:
$ sudo sshd -t # test the config; silent = OK
$ sudo sshd -T # print the fully-resolved effective config
$ sudo sshd -T -C user=backup,host=ci.example.edu,addr=198.51.100.7 | grep -i forcecommand
forcecommand /usr/local/bin/backup-readonly.sh
That last form is the one worth knowing: -C supplies a hypothetical connection so sshd -T evaluates the Match blocks, letting you confirm a rule fires for the user you meant and not for anyone else.
⚠️ Keep a second session open whenever you edit
sshd_configon a remote host. Restart the service in one window and test with the other — if the new config locks you out, the already-authenticated session is your way back in. Recovering otherwise means console access.
Talking to old servers: the ssh-rsa problem
OpenSSH 8.8 disabled RSA signatures using the SHA-1 hash by default, because SHA-1 is no longer collision-resistant. Connecting to elderly gear — an old switch, an unpatched appliance, a legacy jump box — now fails like this:
$ ssh admin@old-switch
Unable to negotiate with 198.51.100.9 port 22: no matching host key type found.
Their offer: ssh-rsa
The fix is to re-enable the algorithm for that host only, never globally:
Host old-switch
HostkeyAlgorithms +ssh-rsa
PubkeyAcceptedAlgorithms +ssh-rsa
The leading + appends to the default set rather than replacing it, so everything else stays modern. Note this is about the SHA-1 signature algorithm, not RSA keys as such — an RSA key used with rsa-sha2-256/rsa-sha2-512 (supported since OpenSSH 7.2) is fine and needs no change. Treat the override as a dated workaround and a prompt to get the far end upgraded.
Escape sequences: rescuing a frozen session
When the network drops, an SSH session hangs and Ctrl-C does nothing — it is being sent to the remote side, which is not listening. SSH has an escape character, ~, recognised only immediately after a newline:
| Sequence | Effect |
|---|---|
~. |
Disconnect — the one to remember for a dead session |
~? |
List all escape sequences |
~^Z |
Suspend SSH to the local shell (fg to return) |
~# |
List forwarded connections |
~C |
Open a command line to add/cancel forwards on a live session |
~B / ~R |
Send a BREAK / request rekeying |
So: press Enter, then ~, then . — the session closes immediately. ~C is the other useful one, letting you add a tunnel you forgot without reconnecting:
ssh> -L 8080:intranet.internal:80
Forwarding port.
ℹ️ On a nested session (you SSH’d from a machine you SSH’d to), each hop consumes one
~, so use~~.to close the second hop and~.to close the outermost.ServerAliveIntervalin the example config makes most of this unnecessary by dropping dead connections on its own.
Key takeaways
- SSH is an encrypted, multiplexed transport — interactive shells, file copy, tunnels, X11, and agent channels over one authenticated connection; the host key (TOFU,
known_hosts) authenticates the server. - Use Ed25519 keys with a passphrase and an agent; keep
~/.ssh700and keys600; leaveForwardAgentoff by default. A security key (ed25519-sk, OpenSSH 8.2+) goes further — the secret cannot be copied off the hardware at all. - The host key authenticates the server, and
known_hostsis where you record it. Treat “REMOTE HOST IDENTIFICATION HAS CHANGED” as a question, not an obstacle: confirm the rebuild out of band, thenssh-keygen -R host. Never reach forStrictHostKeyChecking no— useaccept-newif automation needs it. ~/.ssh/configis the power tool:Hostglob patterns (*,?),%h/%nplaceholders,ProxyJumpfor bastions,HostKeyAliasfor reused IPs, andMatch … !execfor conditional (on/off-campus) behavior.- Copy files over the same channel:
scp(quick — but the port flag is-P, and it’s SFTP-backed since OpenSSH 9.0),sftp(interactive or scripted), orrsync(big or repeated copies) — all reuse your keys, config aliases, andauthorized_keysrestrictions. - Tunnels:
-Lpulls a remote service to you,-Rexposes a local service to the far side,-Dis a SOCKS pivot — remember “who opens the listening port?” Each is also an attacker pivot/exfil technique. authorized_keyscan constrain a key withcommand="…"(forced command +$SSH_ORIGINAL_COMMAND),restrict, andfrom=— turning a login into a least-privilege capability; it’s also prime persistence to audit.- Multiplexing (
ControlMaster/ControlPath/ControlPersist) makes repeat connections near-instant — but the control socket is an authenticated way in, so guard it. - Certificates fix what
authorized_keyscannot: signed, expiring credentials, oneTrustedUserCAKeysline per server, and@cert-authorityto retire host-key prompts entirely. - Precedence: client = first value wins (command line →
~/.ssh/config→/etc/ssh/ssh_config, so specific blocks first andHost *last); server = first matchingMatchwins insshd_config. Debug withssh -Gandsshd -T -C. - When it breaks:
ssh -von the client, the server’s log for the real reason,sshd -tbefore every restart (with a second session open), and~.to kill a frozen session.
References
- OpenSSH
ssh_config(5)— the client config, patterns, andMatch. https://man.openbsd.org/ssh_config.5 - OpenSSH
sshd_config(5)— the server config andMatch. https://man.openbsd.org/sshd_config.5 ssh(1)— the client, including-L/-R/-D/-J/-G. https://man.openbsd.org/ssh.1sshd(8)AUTHORIZED_KEYS FILE FORMAT — per-key options and forced commands. https://man.openbsd.org/sshd.8#AUTHORIZED_KEYS_FILE_FORMATssh-keygen(1)andssh-agent(1). https://man.openbsd.org/ssh-keygen.1- OpenSSH release notes — the authority for which version introduced what. https://www.openssh.com/releasenotes.html
- OpenSSH 8.8 release notes — disabling RSA/SHA-1 signatures, and the
+ssh-rsaworkaround. https://www.openssh.com/txt/release-8.8 - OpenSSH 9.0 release notes —
scpswitching to the SFTP protocol. https://www.openssh.com/txt/release-9.0 - OpenSSH FIDO/U2F security-key support. https://www.openssh.com/txt/release-8.2
ssh-keyscan(1)— collecting host keys. https://man.openbsd.org/ssh-keyscan.1- Facebook Engineering, “Scalable and secure access with SSH” — why certificates replace
authorized_keysat scale. https://engineering.fb.com/2016/09/12/security/scalable-and-secure-access-with-ssh/ - NIST IR 7966 — Security of Interactive and Automated Access Management Using Secure Shell (SSH). https://nvlpubs.nist.gov/nistpubs/ir/2015/NIST.IR.7966.pdf
- M. Friedl et al., RFC 4251 — The Secure Shell (SSH) Protocol Architecture. https://datatracker.ietf.org/doc/html/rfc4251
ssh.comSSH Academy — agent and tunneling reference. https://www.ssh.com/academy/ssh
Related course pages: SSH Tunnel for Windows RDP · Cryptography · Identity and Access Management · Host Security · VPNs and IPSec · Working with Files · Shell and Other Basics
🛠️ Maintenance note: SSH syntax is very stable, but features arrive with specific releases and this page names them:
restrict(7.2),expiry-time(7.7),permitlisten(7.8), FIDO-skkey types (8.2),KbdInteractiveAuthenticationreplacingChallengeResponseAuthentication(8.7), RSA/SHA-1 disabled by default (8.8),scpover SFTP (9.0), andMatch localnetwork(9.4). Check these against the OpenSSH on the course VM and on student laptops — a client older than a named version is the usual reason an example here does not work (Ubuntu 22.04 still ships 8.9, soMatch localnetworkis unavailable there). Confirm the example~/.ssh/configpatterns still reflect the current lab addressing each term, and re-check thessh-rsaworkaround section once the legacy gear it exists for is retired.