Back to Computing & Systems Foundations Series

Part 18: SSH & Secure Remote Access

May 13, 2026Wasil Zafar18 min read

Master SSH — key-based authentication, config management, local/remote/dynamic tunneling, agent forwarding, secure file transfer, and production hardening.

Table of Contents

  1. SSH Protocol Overview
  2. Key-Based Authentication
  3. SSH Config File
  4. SSH Tunneling
  5. SSH Agent & Agent Forwarding
  6. File Transfer — scp & rsync
  7. SSH Hardening
  8. Exercises
  9. Conclusion

SSH Protocol Overview

SSH (Secure Shell) is a cryptographic network protocol for secure remote login, command execution, and tunneling over an untrusted network. It replaced insecure predecessors (Telnet, rlogin, rsh) by encrypting all traffic — including passwords — between client and server. SSH operates on TCP port 22 by default and uses a client-server architecture.

The SSH protocol has three layers: the Transport Layer (key exchange, encryption, integrity), the User Authentication Layer (password, public key, keyboard-interactive), and the Connection Layer (multiplexed channels for shell, exec, forwarding).

SSH Key Authentication Flow
sequenceDiagram
    participant C as Client
    participant S as Server

    C->>S: TCP connect (port 22)
    S->>C: Server version string (SSH-2.0-OpenSSH_9.x)
    C->>S: Client version string (SSH-2.0-OpenSSH_9.x)
    Note over C,S: Key Exchange (Diffie-Hellman / Curve25519)
    S->>C: Server host key (verify fingerprint)
    Note over C,S: Encrypted channel established
    C->>S: Auth request: public key (Ed25519)
    S->>C: Challenge (sign this nonce)
    C->>S: Signature (signed with private key)
    S->>C: Auth success
    Note over C,S: Session established — shell/exec/tunnel
            
Key Insight: SSH establishes security in two phases: (1) the transport layer negotiates encryption using Diffie-Hellman — this protects against eavesdropping even before authentication; (2) user authentication proves identity using keys or passwords. The server's host key (stored in ~/.ssh/known_hosts) prevents man-in-the-middle attacks on subsequent connections.

Key-Based Authentication

Public key authentication is more secure than passwords — no secret is transmitted over the network. The client proves it holds the private key by signing a challenge from the server. The server verifies the signature against the public key listed in ~/.ssh/authorized_keys.

ssh-keygen

# Generate an Ed25519 key pair (RECOMMENDED — modern, fast, small, secure)
ssh-keygen -t ed25519 -C "wasil@workstation" -f ~/.ssh/id_ed25519
# Prompts for passphrase (protect your private key at rest)

# Generate RSA 4096-bit key (wider compatibility, but larger and slower)
ssh-keygen -t rsa -b 4096 -C "wasil@workstation" -f ~/.ssh/id_rsa_4096

# View your public key (this goes on the server)
cat ~/.ssh/id_ed25519.pub
# ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... wasil@workstation

# View the key fingerprint (for verification)
ssh-keygen -lf ~/.ssh/id_ed25519.pub
# 256 SHA256:abc123... wasil@workstation (ED25519)

# Change passphrase on existing key
ssh-keygen -p -f ~/.ssh/id_ed25519
Use Ed25519: Ed25519 keys are 256-bit (vs RSA 4096-bit), produce smaller signatures, are faster to generate and verify, and have no known weaknesses. The only reason to use RSA is compatibility with very old systems (OpenSSH < 6.5, 2014). Always set a passphrase on your private key — without it, anyone with file access can impersonate you.

authorized_keys

# Copy your public key to the server (easiest method)
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@server.example.com
# This appends your public key to ~/.ssh/authorized_keys on the server

# Manual method (if ssh-copy-id unavailable)
cat ~/.ssh/id_ed25519.pub | ssh user@server "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"

# Verify permissions (SSH is strict about permissions)
# On the server:
ls -la ~/.ssh/
# drwx------  .ssh/             (700 — owner only)
# -rw-------  authorized_keys   (600 — owner read/write only)
# -rw-------  id_ed25519        (600 — private key)
# -rw-r--r--  id_ed25519.pub    (644 — public key, readable)

# Restrict a key to specific commands (in authorized_keys)
# command="/usr/bin/backup.sh",no-port-forwarding,no-X11-forwarding ssh-ed25519 AAAA...

SSH Config File

The ~/.ssh/config file saves you from typing long SSH commands repeatedly. It defines per-host settings: hostname, user, key, port, proxy, and more. SSH reads this file on every connection.

# ~/.ssh/config — Example with multiple hosts

# Default settings for all hosts
Host *
    AddKeysToAgent yes
    IdentitiesOnly yes
    ServerAliveInterval 60
    ServerAliveCountMax 3

# Production server (direct)
Host prod
    HostName 10.0.1.50
    User deploy
    IdentityFile ~/.ssh/id_ed25519
    Port 2222

# Jump through bastion to reach internal database server
Host db-internal
    HostName 10.0.2.100
    User dbadmin
    IdentityFile ~/.ssh/id_ed25519
    ProxyJump bastion

# Bastion / jump host
Host bastion
    HostName bastion.example.com
    User wasil
    IdentityFile ~/.ssh/id_ed25519
    ForwardAgent no

# GitHub (use specific deploy key)
Host github.com
    HostName github.com
    User git
    IdentityFile ~/.ssh/github_ed25519

# Wildcard for all staging servers
Host staging-*
    User deploy
    IdentityFile ~/.ssh/id_ed25519_staging
    ProxyJump bastion
# Usage with SSH config (short aliases instead of long commands)
ssh prod                    # Connects to 10.0.1.50:2222 as deploy
ssh db-internal             # Jumps through bastion automatically
ssh staging-web             # Matches wildcard, uses ProxyJump

# ProxyJump (-J) without config file
ssh -J wasil@bastion.example.com dbadmin@10.0.2.100

# Test SSH config parsing
ssh -G prod                 # Show resolved config for "prod" host

SSH Tunneling

SSH tunneling (port forwarding) creates encrypted channels that carry other protocols' traffic through the SSH connection. There are three types: Local (-L), Remote (-R), and Dynamic (-D).

SSH Tunneling Types
flowchart TD
    subgraph Local["-L Local Forwarding"]
        LC[Client :8080] -->|encrypted| LS[SSH Server]
        LS --> LT[Target db:5432]
    end

    subgraph Remote["-R Remote Forwarding"]
        RT[Remote users :80] --> RS[SSH Server :80]
        RS -->|encrypted| RC[Client localhost:3000]
    end

    subgraph Dynamic["-D Dynamic SOCKS"]
        DA[App → SOCKS :1080] -->|encrypted| DS[SSH Server]
        DS --> DT1[Any host:port]
        DS --> DT2[Any host:port]
    end
            

Local Forwarding (-L)

Local forwarding binds a port on your local machine and forwards traffic through the SSH tunnel to a destination reachable from the server. Use case: access a remote database that only listens on localhost.

# Local port forwarding: access remote PostgreSQL via localhost:5433
# Syntax: ssh -L [local_addr:]local_port:remote_host:remote_port server
ssh -L 5433:localhost:5432 user@db-server.example.com -N
# -N = no remote command (tunnel only)

# Now connect to PostgreSQL locally:
psql -h 127.0.0.1 -p 5433 -U myuser mydb

# Access internal web app through bastion
ssh -L 8080:internal-app.corp:80 user@bastion.example.com -N
# Browse http://localhost:8080 → reaches internal-app.corp:80

# Multiple forwards in one connection
ssh -L 5433:db:5432 -L 6380:redis:6379 -L 8080:web:80 user@bastion -N

Remote Forwarding (-R)

Remote forwarding binds a port on the SSH server and forwards traffic back to your local machine (or another host reachable from you). Use case: expose a local dev server to the internet through a VPS.

# Remote port forwarding: expose local dev server to VPS
# Syntax: ssh -R [remote_addr:]remote_port:local_host:local_port server
ssh -R 8080:localhost:3000 user@vps.example.com -N
# Anyone connecting to vps.example.com:8080 reaches your localhost:3000

# Allow external connections (requires GatewayPorts yes in sshd_config)
ssh -R 0.0.0.0:8080:localhost:3000 user@vps.example.com -N

# Expose local SSH to a remote host (reverse SSH tunnel)
ssh -R 2222:localhost:22 user@relay.example.com -N
# From relay: ssh -p 2222 localhost  → reaches your machine

Dynamic/SOCKS Proxy (-D)

Dynamic forwarding creates a local SOCKS proxy. Any application configured to use it routes all TCP traffic through the SSH tunnel — like a lightweight VPN.

# Dynamic SOCKS proxy on localhost:1080
ssh -D 1080 user@server.example.com -N

# Configure applications to use SOCKS5 proxy at 127.0.0.1:1080
# Browser: Settings → Proxy → SOCKS5, 127.0.0.1, port 1080
# curl with SOCKS proxy:
curl --socks5 127.0.0.1:1080 http://internal-service.corp/api/status

# Use with proxychains (route any command through SOCKS)
# /etc/proxychains.conf: socks5 127.0.0.1 1080
proxychains nmap -sT internal-network.corp/24

SSH Agent & Agent Forwarding

The SSH agent holds your decrypted private keys in memory so you don't re-enter your passphrase for every connection. Agent forwarding lets a remote server use your local agent's keys for onward connections (e.g., pulling from GitHub on a server without storing keys there).

# Start the SSH agent (usually auto-started by desktop environment)
eval "$(ssh-agent -s)"
# Agent pid 12345

# Add your key to the agent
ssh-add ~/.ssh/id_ed25519
# Enter passphrase once — agent remembers it

# List keys loaded in agent
ssh-add -l
# 256 SHA256:abc123... wasil@workstation (ED25519)

# Add key with lifetime (auto-removed after 4 hours)
ssh-add -t 4h ~/.ssh/id_ed25519

# Agent forwarding: use local keys on remote server
ssh -A user@bastion.example.com
# On bastion, you can now: git clone git@github.com:org/repo.git
# (uses your local key via forwarded agent)

# In SSH config:
# Host bastion
#     ForwardAgent yes
Agent Forwarding Risks: When you forward your agent to a remote host, anyone with root access on that host can use your agent socket to authenticate as you to any server your keys grant access to — for as long as your session is active. Never forward your agent to untrusted or shared servers. Safer alternatives: use ProxyJump (the intermediate host never sees your keys), or deploy specific limited-scope deploy keys on the server.

File Transfer — scp & rsync

SSH provides secure file transfer via scp (simple copy) and rsync (incremental sync). Both use SSH as the transport layer and respect your SSH config.

# === scp — Secure Copy ===

# Copy local file to remote server
scp ./app.tar.gz user@server:/opt/releases/

# Copy remote file to local machine
scp user@server:/var/log/app.log ./app.log

# Copy entire directory recursively
scp -r ./build/ user@server:/var/www/html/

# Use SSH config alias
scp ./deploy.sh prod:/opt/scripts/

# Specify port and key
scp -P 2222 -i ~/.ssh/id_ed25519 file.txt user@host:/tmp/
# === rsync — Incremental Sync (preferred over scp for large transfers) ===

# Sync local directory to remote (only transfers changed files)
rsync -avz --progress ./project/ user@server:/opt/project/
# -a = archive (preserves permissions, timestamps, symlinks)
# -v = verbose
# -z = compress during transfer
# --progress = show transfer progress

# Sync remote to local (pull)
rsync -avz user@server:/var/log/app/ ./logs/

# Dry run (show what would change without doing it)
rsync -avzn ./project/ user@server:/opt/project/

# Exclude patterns
rsync -avz --exclude='node_modules' --exclude='.git' ./app/ user@server:/opt/app/

# Delete files on destination that don't exist on source (mirror)
rsync -avz --delete ./build/ user@server:/var/www/html/

# Use specific SSH key and port
rsync -avz -e "ssh -p 2222 -i ~/.ssh/id_ed25519" ./data/ user@host:/data/

# Bandwidth limit (KB/s) — don't saturate the link
rsync -avz --bwlimit=5000 ./large-backup/ user@server:/backups/

SSH Hardening

A default SSH configuration is functional but not production-ready. Hardening reduces the attack surface by disabling weak authentication methods, restricting access, and limiting exposure.

# /etc/ssh/sshd_config — Production hardening

# Disable password authentication (keys only)
PasswordAuthentication no
ChallengeResponseAuthentication no
UsePAM yes

# Disable root login
PermitRootLogin no

# Allow only specific users/groups
AllowUsers deploy wasil
# AllowGroups ssh-users

# Use only SSH protocol 2 (protocol 1 is insecure and deprecated)
Protocol 2

# Restrict key exchange and ciphers to strong algorithms
KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com
MACs hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com
HostKeyAlgorithms ssh-ed25519,rsa-sha2-512,rsa-sha2-256

# Limit authentication attempts and timeout
MaxAuthTries 3
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2

# Disable unused features
X11Forwarding no
AllowTcpForwarding no          # Enable only if tunneling is needed
AllowAgentForwarding no        # Enable only on jump hosts
PermitTunnel no

# Change default port (security through obscurity — reduces log noise)
Port 2222

# Restrict to specific listen address (if multi-homed)
ListenAddress 10.0.1.50
# Apply sshd_config changes
sudo sshd -t                   # Test config syntax BEFORE restarting
sudo systemctl restart sshd    # Restart SSH daemon

# Verify hardening with ssh-audit (open-source auditing tool)
# Install: pip install ssh-audit
ssh-audit localhost
ssh-audit server.example.com

# Fail2ban — auto-block IPs after failed login attempts
sudo apt install fail2ban
# /etc/fail2ban/jail.local:
# [sshd]
# enabled = true
# port = 2222
# maxretry = 3
# bantime = 3600
Cloud Pattern

Bastion Host / Jump Box Pattern in Cloud

In production cloud environments, internal servers are in private subnets with no public IPs. Access is routed through a hardened bastion host (jump box) in a public subnet. Modern alternatives eliminate long-lived SSH keys entirely:

  • AWS Systems Manager Session Manager — browser/CLI shell access with no open inbound ports, IAM-authenticated, full audit trail in CloudTrail
  • GCP Identity-Aware Proxy (IAP) — SSH tunneling through Google's identity proxy, no public IP on instances
  • Azure Bastion — PaaS bastion service, SSH/RDP via Azure Portal, no public IPs on VMs
  • ProxyJump — in ~/.ssh/config, chains through bastion without storing keys on intermediate host

Trend: short-lived SSH certificates (HashiCorp Vault SSH CA, AWS EC2 Instance Connect) replace long-lived keys, providing automatic rotation and audit logging.

BastionZero TrustSession ManagerProxyJump

Exercises

# Exercise 1: Generate an Ed25519 key and inspect it
ssh-keygen -t ed25519 -C "exercise-key" -f /tmp/exercise_key -N ""
ssh-keygen -lf /tmp/exercise_key.pub
cat /tmp/exercise_key.pub

# Exercise 2: Create an SSH config with aliases
cat <<'EOF' > /tmp/ssh_config_test
Host myserver
    HostName 192.168.1.100
    User admin
    IdentityFile ~/.ssh/id_ed25519
    Port 22

Host internal
    HostName 10.0.0.50
    User deploy
    ProxyJump myserver
EOF
ssh -F /tmp/ssh_config_test -G myserver   # Verify config parsing

# Exercise 3: Local port forwarding (test with nc)
# Terminal 1 (simulated remote service):
nc -l 9999 &
# Terminal 2 (SSH tunnel — requires sshd running locally):
# ssh -L 8888:localhost:9999 localhost -N &
# Terminal 3: echo "tunneled" | nc 127.0.0.1 8888

# Exercise 4: Inspect your SSH agent
ssh-add -l 2>/dev/null || echo "No agent or no keys loaded"

# Exercise 5: Audit SSH server configuration
# ssh-audit localhost  (install: pip install ssh-audit)

Conclusion & Next Steps

SSH is the backbone of secure remote administration. Key-based authentication eliminates password vulnerabilities. The SSH config file turns complex multi-hop connections into simple aliases. Tunneling (-L, -R, -D) provides encrypted channels for any protocol. Agent forwarding enables seamless multi-hop workflows but requires caution. And hardening (disabling passwords, restricting ciphers, limiting access) transforms SSH from functional to production-secure. Combined with bastion patterns and short-lived certificates, SSH remains the primary secure access mechanism even in cloud-native environments.