SSH is ubiquitous, yet most of us barely scratch its surface. We authenticate, we connect, we move on. But SSH has a remarkable depth of functionality that can transform how you manage infrastructure, secure access, and automate workflows. This post explores the corners of SSH that deserve more attention.
The Client Configuration File
The ~/.ssh/config file is perhaps SSH's most underutilised feature. Rather than typing out connection details repeatedly, you can define hosts with all their parameters:
Host buildserver
HostName 192.168.1.50
User deploy
Port 2222
IdentityFile ~/.ssh/buildserver_ed25519
ForwardAgent no
Host *.internal.example.com
User alan
ProxyJump bastion
IdentityFile ~/.ssh/internal_ed25519
Now ssh buildserver handles everything. But the configuration system goes much deeper.
Pattern matching allows wildcard hosts. The asterisk matches any sequence of characters, and the question mark matches exactly one character. Configuration is applied in order, with the first matching value for each option taking precedence.
Host prod-*
User deployer
IdentityFile ~/.ssh/prod_key
Host dev-*
User developer
IdentityFile ~/.ssh/dev_key
Host *
ServerAliveInterval 60
ServerAliveCountMax 3
AddKeysToAgent yes
The Match directive provides conditional configuration based on criteria beyond just hostname:
Match host *.example.com exec "test -f ~/.ssh/vpn_connected"
ProxyJump none
Match host *.example.com
ProxyJump bastion.example.com
This checks whether a VPN indicator file exists before deciding whether to use a jump host.
CanonicalizeHostname resolves short hostnames to their fully qualified versions before matching, which is useful when you have internal DNS:
CanonicalDomains internal.example.com example.com
CanonicalizeHostname yes
CanonicalizeMaxDots 0
Host webserver
# Will match webserver.internal.example.com
User www-data
Restricting Commands with authorized_keys
This is where SSH becomes genuinely powerful for automation and security. Each public key in ~/.ssh/authorized_keys can have options that constrain what that key can do.
The command option restricts a key to running exactly one command, regardless of what the connecting client requests:
command="/usr/local/bin/backup-script" ssh-ed25519 AAAA... backup@automation
When someone connects with this key, /usr/local/bin/backup-script runs instead of whatever they asked for. The original command is available in the SSH_ORIGINAL_COMMAND environment variable, which enables some interesting patterns:
#!/bin/bash
# /usr/local/bin/git-shell-wrapper
case "$SSH_ORIGINAL_COMMAND" in
git-upload-pack*|git-receive-pack*)
exec $SSH_ORIGINAL_COMMAND
;;
*)
echo "Only git operations permitted"
exit 1
;;
esac
This allows git operations while blocking everything else.
Network restrictions limit where connections can come from:
from="192.168.1.0/24,10.0.0.5" ssh-ed25519 AAAA... admin@internal
Disabling features prevents various SSH capabilities:
no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAA... automated@script
The no-pty option prevents allocation of a pseudo-terminal, which makes the key unsuitable for interactive use but perfectly fine for automated commands that just need to run and return output.
Combining restrictions creates tightly scoped access:
command="/usr/bin/rsync --server --daemon .",from="backup.example.com",no-pty,no-port-forwarding ssh-ed25519 AAAA... rsync@backup
This key can only be used from one host, can only run rsync in server mode, cannot allocate a terminal, and cannot forward ports.
The restrict option (OpenSSH 7.2+) provides a sensible default that disables most features, which you can then selectively re-enable:
restrict,command="/usr/local/bin/deploy",pty ssh-ed25519 AAAA... deploy@ci
This disables everything except what's explicitly allowed.
Certificate-Based Authentication
SSH certificates solve the problem of key distribution at scale. Instead of copying public keys to every server, you sign keys with a Certificate Authority, and servers trust that CA.
Generate a CA key pair:
ssh-keygen -t ed25519 -f ca_key -C "SSH CA"
Sign a user's public key:
ssh-keygen -s ca_key -I alan@example -n alan,deployer -V +52w user_key.pub
This creates user_key-cert.pub valid for one year, allowing authentication as either alan or deployer.
On servers, trust the CA by adding to /etc/ssh/sshd_config:
TrustedUserCAKeys /etc/ssh/ca_key.pub
Certificates can include restrictions just like authorized_keys entries:
ssh-keygen -s ca_key -I backup-automation -n backup \
-O clear -O no-pty -O force-command=/usr/local/bin/backup \
-O source-address=10.0.0.0/8 \
-V +24h backup_key.pub
Breaking this down:
-s ca_key— sign using the CA private key-I backup-automation— certificate identity (appears in logs)-n backup— principal name (which user account can authenticate)-O clear— reset all permissions to denied-O no-pty— prevent terminal allocation-O force-command=/usr/local/bin/backup— only this command can run-O source-address=10.0.0.0/8— restrict to internal network-V +24h— certificate expires in 24 hoursbackup_key.pub— the public key to sign
This creates a short-lived certificate that can only run the backup command from internal addresses.
Host certificates work similarly, solving the "trust this host key?" problem:
ssh-keygen -s ca_key -I webserver.example.com -h \
-n webserver.example.com,webserver,192.168.1.10 \
/etc/ssh/ssh_host_ed25519_key.pub
Clients that trust the CA will automatically accept this host without prompting.
Jump Hosts and ProxyCommand
Sometimes you can't reach a server directly — it's on a private network, behind a firewall, or only accessible from a specific machine. Jump hosts (also called bastion hosts) let you route your SSH connection through an intermediate server to reach the final destination. ProxyCommand provides the same capability with more flexibility for custom setups.
The ProxyJump directive (or -J flag) routes connections through intermediate hosts:
Host internal-db
HostName 10.0.0.50
ProxyJump bastion.example.com
Multiple jumps chain together:
Host deep-internal
HostName 192.168.1.100
ProxyJump bastion,internal-gateway
ProxyCommand offers more flexibility when you need custom proxy behaviour:
Host *.onion
ProxyCommand socat - SOCKS4A:localhost:%h:%p,socksport=9050
Host corporate-*
ProxyCommand corkscrew proxy.corp.com 8080 %h %p
The first routes .onion addresses through Tor; the second tunnels through an HTTP proxy.
Port Forwarding Patterns
Local forwarding (-L) creates a listening socket on your machine that forwards to a remote destination:
ssh -L 5432:db.internal:5432 bastion
Now localhost:5432 connects to the internal database via the bastion.
Remote forwarding (-R) works in reverse, exposing a local service through the remote host:
ssh -R 8080:localhost:3000 public-server
Anyone connecting to public-server:8080 reaches your local development server.
Dynamic forwarding (-D) creates a SOCKS proxy:
ssh -D 1080 server
Configure your browser or application to use localhost:1080 as a SOCKS5 proxy, and all traffic routes through the server.
Restricting forwarding on the server can be done in sshd_config:
AllowTcpForwarding local
PermitOpen 10.0.0.0/8:*
GatewayPorts no
This allows local forwarding only, restricts destinations to internal addresses, and prevents remote forwards from binding to public interfaces.
SSH Tunnelling
Port forwarding covers specific ports, but SSH can create full network tunnels that route arbitrary traffic. This is useful when you need more than point-to-point forwarding.
TUN/TAP Tunnelling
TUN/TAP tunnelling creates virtual network interfaces. TUN operates at layer 3 (IP packets), TAP at layer 2 (ethernet frames):
# On client
ssh -w 0:0 root@server
# This creates tun0 on both ends
The -w local:remote specifies tunnel device numbers. Both sides need configuration in sshd_config:
PermitTunnel point-to-point # or 'ethernet' for TAP, 'yes' for both
After the tunnel establishes, configure the interfaces:
# Client side
ip addr add 10.10.10.1/32 peer 10.10.10.2 dev tun0
ip link set tun0 up
# Server side
ip addr add 10.10.10.2/32 peer 10.10.10.1 dev tun0
ip link set tun0 up
Now you have a routable point-to-point link. Add routes to direct traffic through it:
# Route a subnet through the tunnel
ip route add 192.168.50.0/24 via 10.10.10.2
Practical Uses for TUN Tunnels
Accessing an entire remote subnet rather than individual ports. If you need to reach dozens of services on 10.0.0.0/24, a tunnel with appropriate routing beats maintaining dozens of port forwards.
Building a poor man's VPN when you can't install proper VPN software. The server needs IP forwarding enabled and appropriate iptables rules:
# On server
echo 1 > /proc/sys/net/ipv4/ip_forward
iptables -t nat -A POSTROUTING -s 10.10.10.0/24 -o eth0 -j MASQUERADE
TAP Tunnelling
TAP tunnelling bridges ethernet segments. This is heavier but allows broadcast traffic and non-IP protocols:
ssh -o Tunnel=ethernet -w 0:0 root@server
TAP interfaces can join bridges, making two physically separate networks appear as one broadcast domain. Useful for certain clustering scenarios or when you need ARP to work across the tunnel.
Combining Tunnels with Configuration
You can automate tunnel setup in your SSH config:
Host remote-lan
HostName gateway.example.com
Tunnel point-to-point
TunnelDevice 0:0
PermitLocalCommand yes
LocalCommand ip addr add 10.10.10.1/32 peer 10.10.10.2 dev tun0 && ip link set tun0 up
The LocalCommand runs after connection, automating the interface setup.
Tunnelling vs SOCKS Proxy
Dynamic forwarding (-D) creates a SOCKS proxy, which works well for applications that support SOCKS. TUN tunnelling works at the IP level, capturing all traffic regardless of application support. The tradeoff is that TUN requires root privileges and more setup, while SOCKS works as an unprivileged user.
For most cases, SOCKS suffices:
ssh -D 1080 -fN bastion
# Route specific traffic through it
curl --proxy socks5h://localhost:1080 http://internal.service/
The socks5h variant does DNS resolution on the remote side, which matters when internal hostnames aren't resolvable locally.
Transparent Proxying
If you need to route traffic from applications that don't support SOCKS, tools like tun2socks create a TUN interface that forwards to a SOCKS proxy:
ssh -D 1080 -fN bastion
tun2socks -device tun0 -proxy socks5://127.0.0.1:1080
# Configure interface
ip addr add 10.10.10.1/24 dev tun0
ip link set tun0 up
ip route add 192.168.0.0/16 dev tun0
This gives you tunnel-like behaviour without needing root on the remote server.
Security Considerations for Tunnelling
Tunnelling effectively extends your network perimeter. A compromised client with tunnel access can route attacks into your internal network. Restrict tunnel permissions carefully:
# sshd_config
PermitTunnel no
Match User vpn-user
PermitTunnel point-to-point
Consider whether you actually need full tunnelling or whether targeted port forwards would suffice with less risk.
Multiplexing and Connection Sharing
SSH can share a single TCP connection across multiple sessions, dramatically speeding up repeated connections:
Host *
ControlMaster auto
ControlPath ~/.ssh/sockets/%r@%h-%p
ControlPersist 600
The first connection to a host establishes the master; subsequent connections reuse it. ControlPersist 600 keeps the master alive for ten minutes after the last session closes.
This matters more than you might think. Without multiplexing, each connection requires TCP handshake, key exchange, and authentication. With multiplexing, additional sessions are nearly instant.
Managing multiplexed connections: The -O flag sends control commands to an existing master connection from another terminal.
Check if a master connection exists:
ssh -O check server
Add a port forward through the existing master without opening a new session:
ssh -O forward -L 8080:localhost:80 server
Close the master connection when finished:
ssh -O exit server
Environment Variables
SSH starts a clean shell by default — your local environment doesn't carry across to the remote session. This is usually what you want for security, but sometimes you need specific variables to follow you.
Common use cases include locale settings (so output displays correctly) and Git configuration (so commits have the right identity). To send variables, configure your client:
# Client config (~/.ssh/config)
Host server
SendEnv LANG LC_* GIT_*
The server must explicitly accept these variables in sshd_config:
# Server sshd_config
AcceptEnv LANG LC_* GIT_*
This server whitelist is a security boundary — arbitrary environment variables can alter program behaviour in unexpected ways, so servers only accept what they've explicitly allowed.
If you don't control the server configuration, SetEnv offers an alternative that doesn't require server cooperation:
Host server
SetEnv EDITOR=vim PAGER=less
These variables are set on the remote side regardless of the server's AcceptEnv configuration.
Escape Sequences
SSH sessions can hang — the network drops, the remote server freezes, or something else goes wrong. Closing your terminal works, but you lose your local shell state. SSH provides escape sequences to handle these situations without killing your terminal.
The escape character is ~, but it's only recognised immediately after a newline. Press Enter, then the escape sequence:
~. | Disconnect immediately, even if the session is hung |
~^Z | Suspend SSH and return to local shell |
~# | List forwarded connections |
~C | Open command line for adding/removing forwards |
~& | Background SSH while waiting for forwards to close |
~? | Show all escape sequences |
The most important is ~. — your escape hatch when a connection freezes. Instead of closing the terminal, press Enter, then ~. and you're cleanly disconnected.
The command line (~C) lets you add or remove port forwards without restarting the session:
ssh> -L 8080:localhost:80
Forwarding port.
ssh> -KL 8080
Cancelled forwarding.
For chained SSH sessions (SSH into a server, then SSH again to another), double the tilde to reach the inner connection: ~~. disconnects the inner session while keeping the outer one alive.
AuthorizedKeysCommand
Instead of static authorized_keys files, you can fetch keys dynamically:
# sshd_config
AuthorizedKeysCommand /usr/local/bin/fetch-keys %u
AuthorizedKeysCommandUser nobody
The script receives the username and should output authorized_keys format:
#!/bin/bash
# /usr/local/bin/fetch-keys
USER=$1
curl -sf "https://keys.internal/users/$USER/keys"
This enables central key management, integration with identity providers, and dynamic access control based on whatever logic you need.
Hardening sshd_config
A few settings worth considering:
# Require specific users or groups
AllowUsers deploy admin@10.0.0.*
AllowGroups ssh-users
# Restrict authentication methods
AuthenticationMethods publickey
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
# Limit login attempts
MaxAuthTries 3
MaxSessions 5
LoginGraceTime 30
# Require recent key types
PubkeyAcceptedAlgorithms ssh-ed25519,sk-ssh-ed25519@openssh.com
# Disable unused features
X11Forwarding no
AllowAgentForwarding no
PermitTunnel no
Per-user or per-group overrides use Match blocks:
Match Group developers
AllowTcpForwarding yes
PermitTunnel yes
Match User deploy
AllowTcpForwarding local
PermitOpen 10.0.0.0/8:*
ForceCommand /usr/local/bin/deploy-wrapper
Practical Applications
Restricted deployment key:
command="cd /var/www/app && git pull && systemctl reload app",no-port-forwarding,no-X11-forwarding,no-agent-forwarding ssh-ed25519 AAAA... deploy@ci
Database tunnel with automatic local forward:
Host db-tunnel
HostName bastion.example.com
LocalForward 5432 db.internal:5432
RequestTTY no
ExitOnForwardFailure yes
Running ssh -fN db-tunnel establishes the tunnel in the background.
Git access with validation:
#!/bin/bash
# forced command script
if [[ "$SSH_ORIGINAL_COMMAND" =~ ^git-(upload|receive)-pack\ \'/[a-zA-Z0-9_-]+\.git\'$ ]]; then
exec git-shell -c "$SSH_ORIGINAL_COMMAND"
fi
logger -t git-access "Rejected command from $SSH_CLIENT: $SSH_ORIGINAL_COMMAND"
exit 1
Short-lived access via certificates:
# Grant someone temporary access
ssh-keygen -s ca_key -I "contractor-jane-$(date +%Y%m%d)" \
-n deployer -V +8h -O source-address=203.0.113.50 \
contractor_key.pub
Eight hours of access from a specific IP, then the certificate expires.
Full subnet access via tunnel:
#!/bin/bash
# vpn-connect.sh - Establish tunnel and configure routing
ssh -f -w 0:0 root@gateway sleep 3600 &
sleep 1
# Configure local interface
sudo ip addr add 10.10.10.1/32 peer 10.10.10.2 dev tun0
sudo ip link set tun0 up
# Route internal subnets through tunnel
sudo ip route add 10.0.0.0/8 via 10.10.10.2
sudo ip route add 192.168.0.0/16 via 10.10.10.2
echo "Tunnel established. Internal networks now accessible."
SSH rewards deep exploration. These features exist because real operational needs demanded them. Whether you're managing a handful of servers or architecting access control for a larger infrastructure, understanding what SSH can actually do opens up solutions that might otherwise require additional tooling or complexity.