ssh_explained
// How SSH works end-to-end — connection, auth, tunnels, and the crypto underneath
0 / 9
OVERVIEW
How SSH establishes a secure channel
SSH (Secure Shell) replaces telnet and rlogin by wrapping every byte in authenticated encryption. Before you type a single command, it completes a handshake that establishes: ① A shared secret — using ECDH key exchange (never transmitted over the wire) ② Identity verification — confirming you are talking to the right server ③ User authentication — proving who you are to the server Press Step or Play to walk through each phase.
Port 22/TCP · Protocol: SSH-2.0 · Replaces: telnet, rsh, rlogin
Client State
CLOSED
Server State
LISTEN :22
💻
Client
your machine
🖥
Server
:22 / sshd
packet inspector
status
Waiting for first step…
// Why SSH replaced Telnet — what an attacker sees on the wire
⚠ Telnet (plaintext)
Captured on wire: login: ali Password: MyS3cr3tP@ss $ cat /etc/passwd root:x:0:0:root:/root:/bin/bash ali:x:1000:1000::/home/ali:/bin/bash
Username and password visible in plaintext
All commands visible
All output visible
Anyone on the LAN can intercept
✓ SSH (encrypted)
Captured on wire: SSH-2.0-OpenSSH_8.9 ← version only 3a f2 9c 11 84 2b e7 a1 ... f8 03 5c d9 b2 70 4a 11 ... 91 c2 77 3e 0f d8 88 56 ... ↑ random-looking ciphertext, nothing readable
Credentials encrypted + authenticated
Commands and output encrypted
MITM prevented by host key verification
Forward secrecy — past sessions safe if key leaks
How password auth works
The client sends your password encrypted inside the SSH tunnel — not in plaintext like Telnet. The server decrypts it and passes it to PAM (Pluggable Authentication Module) which hashes it and compares against /etc/shadow. Simple to use, but vulnerable to brute-force if the server is exposed to the internet.
Authentication flow
client
SSH_MSG_USERAUTH_REQUEST (method=none)
Client asks: "What auth methods do you accept?" Sends a probe with method=none first.
server
SSH_MSG_USERAUTH_FAILURE (methods: publickey,password)
Server lists accepted methods. Client picks one.
client
🔒 SSH_MSG_USERAUTH_REQUEST (method=password, user=ali, password=••••)
Password is sent encrypted inside the SSH tunnel — an eavesdropper cannot read it. The wire shows random ciphertext.
server
PAM checks: hash(password) == /etc/shadow entry?
Password is hashed (bcrypt/SHA-512) and compared. The hash is stored, never the raw password.
server
🔒 SSH_MSG_USERAUTH_SUCCESS
Access granted. Channel open proceeds.
Pros
No setup — works out of the box
Familiar to all users
Easy to change / reset
Cons
Vulnerable to brute-force and credential stuffing
Password reuse across servers is a risk
No per-key auditing (all logins look the same)
Best practice: disable password auth, use keys
Step 0 — Generate a key pair (one time)
01
ssh-keygen -t ed25519
Generate a key pair. Ed25519 is modern, fast, and small. RSA 4096 is the legacy alternative.
02
Two files created
Private key stays on YOUR machine. Public key goes to the server.
03
ssh-copy-id user@server
Appends your public key to ~/.ssh/authorized_keys on the server. One-time setup.
🔐 Private Key
~/.ssh/id_ed25519
Your secret. Never leaves your machine. Protected with a passphrase. Used to sign the challenge during auth.
-----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAA... (secret) -----END OPENSSH PRIVATE KEY-----
🔓 Public Key
~/.ssh/id_ed25519.pub → server's authorized_keys
Safe to share. Placed in ~/.ssh/authorized_keys on every server you want access to. Anyone can see it — useless without the private key.
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBr2... ali@laptop
Challenge-Response Authentication Flow
client
🔒 USERAUTH_REQUEST (method=publickey, key=ed25519 AAAAC3Nz…)
Client says: "I want to log in as ali, using this public key." It sends the key fingerprint — NOT the private key. Private key never leaves the machine.
server
Check ~/.ssh/authorized_keys for this key fingerprint
Scans authorized_keys line by line. If the key is found, server proceeds. If not found → USERAUTH_FAILURE.
server
🔒 SSH_MSG_USERAUTH_PK_OK — key accepted, now prove you own the private key
Server knows the public key is authorized. Now it needs proof that the client actually has the matching private key — the challenge-response begins.
local
Sign(session_id ‖ username ‖ "ssh-connection" ‖ public_key) using id_ed25519
Client creates a cryptographic signature over a message that includes the session ID (unique to this connection). Private key is used locally — never sent. If key has a passphrase, the user is prompted here.
client
🔒 USERAUTH_REQUEST (method=publickey, signed=true, signature=…)
Sends the signature. The signature proves: "I have the private key that matches the authorized public key." Replay attacks are prevented because the signature includes the unique session ID.
server
Verify(signature, public_key, session_id + message) → valid?
Uses the public key from authorized_keys to verify the signature. If verification passes, the client provably owns the private key.
server
🔒 SSH_MSG_USERAUTH_SUCCESS
Authenticated. The private key was never transmitted — only the signature.
Pros
Private key never leaves your machine
Immune to brute-force (no password to guess)
Multiple keys = per-device audit trail
Works with hardware security keys (YubiKey)
Can be passphrase-protected + stored in ssh-agent
Considerations
One-time setup required (keygen + copy)
Must protect private key file (chmod 600)
Scale problem: authorized_keys per server adds up
Solution for scale: use certificate auth (next tab)
Certificate Auth — scaling public keys for enterprise
Instead of copying your public key to every server's authorized_keys, a Certificate Authority (CA) signs your public key. Servers only need to trust the CA — no per-server key distribution. Certs can have expiry times (e.g. 8 hours), so a leaked cert automatically becomes useless.
01
User has a key pair
id_ed25519 + id_ed25519.pub — same as regular public key auth.
02
CA signs the public key
ssh-keygen -s ca_key -I ali@laptop -n ali -V +8h id_ed25519.pub → id_ed25519-cert.pub
03
Server trusts the CA
sshd_config: TrustedUserCAKeys /etc/ssh/ca.pub — one line, no per-user authorized_keys needed.
Certificate Authentication Flow
CA
Sign user's public key → issue certificate with: principals, expiry, extensions
CA signs: username allowed (principals), valid until (expiry), what operations permitted (extensions: permit-pty, permit-port-forwarding, etc.)
client
🔒 USERAUTH_REQUEST (method=publickey, key=id_ed25519-cert.pub)
Client presents the signed certificate (not just a public key). The cert contains: the public key + CA signature + principals + expiry.
server
Verify CA signature on cert → check expiry → check principals → challenge-response
Server verifies: (1) cert was signed by the trusted CA, (2) cert is not expired, (3) principal matches the login username, (4) client actually has the private key (same challenge-response as public key auth).
server
🔒 SSH_MSG_USERAUTH_SUCCESS
Authenticated without any per-user authorized_keys file on the server.
Pros
No authorized_keys management per server
Short-lived certs auto-expire (no manual revocation)
Centralized access control via CA
Audit: every cert says WHO issued it and when
Works with HashiCorp Vault SSH Secrets Engine
Considerations
Requires a CA infrastructure to set up
CA key compromise = all certs compromised
More complex than simple public key auth
Used by: Uber, Facebook, Netflix at scale
SSH is not just a remote shell — it is a general-purpose encrypted transport layer. Once the SSH connection is established, you can route any TCP traffic through it. This is SSH port forwarding, and it is one of the most powerful and underused features in networking.
ssh -L 5433:db.internal:5432 user@bastion.company.com
encrypted SSH tunnel
plain TCP (inside firewall)
your local TCP
Traffic flow — Local Port Forward
Your App
localhost:5433
local TCP
SSH Client
your machine
🔒 SSH encrypted tunnel
Bastion
internet-facing
plain TCP (internal)
db.internal
:5432 (Postgres)
Use case: Access an internal database through a bastion host
The database server is not exposed to the internet — it only accepts connections from inside the corporate network. The bastion host is the only internet-facing server. This command creates a listener on YOUR machine at port 5433. When your app (e.g. DBeaver, psql) connects to localhost:5433, SSH forwards that traffic through the encrypted tunnel to the bastion, which then connects to db.internal:5432.
1
You connect: psql -h localhost -p 5433 -U ali mydb
2
SSH client receives the connection on localhost:5433
3
SSH wraps it in an encrypted channel and sends it through the SSH tunnel to bastion.company.com
4
Bastion opens a plain TCP connection to db.internal:5432 (this hop is inside the firewall, not internet-exposed)
5
Postgres responds — data flows back through the same chain in reverse
Syntax breakdown: -L [bind_addr:]local_port:remote_host:remote_port
Omit bind_addr to bind to localhost only (safe). Add 0.0.0.0 as bind_addr to let others on your LAN use the tunnel too.
ssh -R 8080:localhost:3000 user@public-server.com
encrypted SSH tunnel
plain TCP (other side)
Traffic flow — Remote Port Forward
External User
browser
plain TCP
Public Server
:8080 listener
🔒 SSH tunnel
Your Machine
SSH client
local TCP
Your App
localhost:3000
Use case: Expose your local dev server to the internet (no port forwarding needed)
You are running a local Node.js app on port 3000 behind a NAT/router. A client wants to see your demo without deploying it. Remote port forwarding creates a listener on the public server — anyone hitting public-server.com:8080 gets tunneled back to your laptop's port 3000. Think: ngrok, but built into SSH.
1
SSH creates a listener on public-server.com:8080 (the REMOTE side)
2
External user hits http://public-server.com:8080
3
Traffic travels back through the SSH tunnel (encrypted) to your machine
4
Your SSH client connects to localhost:3000 — your dev server responds
Note: Requires GatewayPorts yes in sshd_config on the public server to accept connections from non-localhost. Without it, the listener only binds to 127.0.0.1 on the server.
ssh -D 1080 user@remote-server.com
encrypted SSH tunnel
plain TCP (from server's perspective)
Traffic flow — Dynamic SOCKS5 Proxy
Your Browser
SOCKS5: localhost:1080
🔒 SSH (any destination)
SSH Server
acts as proxy
plain TCP to destination
Any Host
google.com, etc.
Use case: Route all browser traffic through a remote server (poor-man's VPN)
Unlike -L (which forwards a single port) and -R (which creates one reverse tunnel), -D creates a SOCKS5 proxy. ANY TCP connection from your browser can be routed through it — websites, APIs, everything. The SSH server becomes your exit node. Set your browser's proxy to SOCKS5 localhost:1080. All HTTP/HTTPS traffic from the browser goes through the encrypted SSH tunnel to the remote server, which makes the outbound connection on your behalf.
1
SSH opens a SOCKS5 listener on YOUR machine at port 1080
2
Configure browser: Settings → Proxy → SOCKS5 → 127.0.0.1:1080
3
Browser requests google.com → sent via SOCKS5 to SSH client
4
SSH wraps it, sends through encrypted tunnel to remote server
5
Remote server connects to google.com — Google sees the remote server's IP, not yours
DNS leak warning: Some browsers resolve DNS locally even when using SOCKS5. In Firefox: set network.proxy.socks_remote_dns = true in about:config to also tunnel DNS through the proxy.
ssh -J user@bastion.company.com user@internal-server.local
Traffic flow — Jump Host (ProxyJump)
Your Machine
SSH client
🔒 SSH tunnel 1
Bastion
jump host
🔒 SSH tunnel 2
Internal Server
not internet-exposed
Use case: SSH directly to an internal server through a bastion, one command
The internal server has no public IP. The bastion is the only SSH-accessible entry point. Without -J, you would have to SSH to the bastion first, then SSH again from there. With -J (ProxyJump), it all happens in one command — SSH establishes a tunnel to the bastion, then creates a new SSH connection inside that tunnel to the internal server. Crucially: the SSH connection to the internal server is end-to-end encrypted from YOUR machine to the internal server. The bastion only forwards bytes — it cannot see the inner SSH session.
1
SSH connects to bastion.company.com and authenticates
2
Through the bastion, SSH opens a TCP channel to internal-server.local:22
3
A second, independent SSH handshake runs inside that channel — end-to-end from your machine
4
You get a shell on internal-server.local as if you connected directly
~/.ssh/config equivalent:
Host internal-server
  HostName internal-server.local
  User ali
  ProxyJump bastion.company.com
After this, just type: ssh internal-server
// How ECDH Key Exchange Creates a Shared Secret Without Transmitting It
This is the magic at the heart of SSH (and TLS). Both sides need the same encryption key to communicate. But they cannot send the key over the network — an eavesdropper could intercept it. ECDH (Elliptic Curve Diffie-Hellman) lets both sides independently compute the same shared secret using only public information.
Client
Generate: ephemeral key pair (c_priv, c_pub)
Send to server: c_pub (public key only)
Receive from server: s_pub
Compute: shared = c_priv × s_pub
c_priv NEVER leaves this machine
Server
Generate: ephemeral key pair (s_priv, s_pub)
Send to client: s_pub (public key only)
Receive from client: c_pub
Compute: shared = s_priv × c_pub
s_priv NEVER leaves this machine
Due to elliptic curve math: c_priv × s_pub == s_priv × c_pub
✓ Both sides compute the SAME shared secret — without ever sending private keys
Forward Secrecy: These ephemeral keys are discarded after the session. Even if an attacker records all your SSH traffic today and later steals the server's host key, they cannot decrypt the recorded sessions — the ephemeral keys are gone. This property is called Perfect Forward Secrecy (PFS).
// Negotiated Algorithms (SSH_MSG_KEXINIT)
Both sides send a list of algorithms they support. The first algorithm in the client's list that the server also supports wins. Preference order matters.
Key Exchange (kex_algorithms)Forward SecrecyNotes
curve25519-sha256recommended✓ ephemeralCurve25519 ECDH + SHA-256 hash. Fast, small keys, strong security.
ecdh-sha2-nistp256default✓ ephemeralNIST P-256 curve. Widely supported, some distrust NIST curves.
diffie-hellman-group14-sha256✓ ephemeralClassic DH with 2048-bit group. Slower than ECDH.
diffie-hellman-group1-sha1legacy✓ ephemeral768-bit DH, SHA-1. Disabled by default in OpenSSH 7.0+.
Encryption (cipher_algorithms)TypeNotes
chacha20-poly1305@openssh.comrecommendedAEADChaCha20 stream cipher + Poly1305 MAC in one. No timing attacks. Default in modern OpenSSH.
aes256-gcm@openssh.comdefaultAEADAES-256-GCM authenticated encryption. Hardware-accelerated on most CPUs.
aes128-gcm@openssh.comAEADAES-128-GCM. Slightly faster than 256, still secure.
aes256-ctrCTR + MACAES-256 counter mode. Requires separate HMAC. Older but still secure.
3des-cbclegacyCBCTriple DES. Very slow, CBC mode has padding oracle risks. Avoid.
MAC (mac_algorithms)Notes
hmac-sha2-256-etm@openssh.comrecommendedEncrypt-then-MAC. EtM ordering prevents padding oracle attacks. Default when using non-AEAD ciphers.
hmac-sha2-512-etm@openssh.comLarger MAC (512-bit). EtM ordering. Slightly more overhead.
hmac-sha2-256MAC-then-Encrypt ordering. Less preferred than EtM variants.
hmac-md5legacyMD5 is broken for collision resistance. Disabled in OpenSSH 7.0+.
Host Key Types (server_host_key_algorithms)Key SizeNotes
ssh-ed25519recommended256-bit (32 bytes)EdDSA over Curve25519. Fast, small, immune to weak RNG. Modern choice.
ecdsa-sha2-nistp256default256-bit NIST P-256ECDSA. Fast but NIST curves have some controversy.
rsa-sha2-512≥2048 bitsRSA with SHA-512 signing. Widely compatible, larger keys.
ssh-rsa (SHA1)legacy≥2048 bitsRSA with SHA-1. Disabled in OpenSSH 8.8+.
ssh-dsslegacy1024-bit onlyDSA. 1024-bit key is too small. Disabled in OpenSSH 7.0+.
// SSH Binary Packet Protocol — What Every Encrypted Packet Looks Like
After NEWKEYS, every SSH message is wrapped in this binary format (before encryption):
packet_length
4 bytes
padding_length
1 byte
payload (msg type + data)
variable
random padding
padding_length bytes
MAC / AEAD tag
0 or 16-32 bytes
packet_length — length of the rest (padding_length + payload + padding), encrypted with the payload. An attacker sees only the encrypted bytes, not the message size (with ChaCha20-Poly1305 even this is encrypted via the length key).
padding — random bytes to pad the packet to a block boundary. Also prevents traffic analysis by randomizing packet sizes slightly.
sequence number — implicit counter, not in the packet but included in the MAC calculation. Prevents replay attacks.
// ~/.ssh/known_hosts — How SSH Prevents Man-in-the-Middle Attacks
When you connect to a server for the first time, SSH stores its public host key fingerprint in ~/.ssh/known_hosts. On every future connection, SSH verifies that the server presents the same key. If the key changes, SSH loudly warns you — this is your protection against MITM attacks.
github.com,140.82.121.3 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GkZu |1|AbCdEf...= ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7... ↑ hashed hostname (HashKnownHosts yes) ↑ key type ↑ base64 public key
1st connect
Server presents its host key. SSH has never seen it before. You see:
The authenticity of host 'server.com (1.2.3.4)' can't be established.
ED25519 key fingerprint is SHA256:abc123...
Are you sure you want to continue connecting? (yes/no/[fingerprint])

If you type yes, the key is saved. Best practice: verify the fingerprint out-of-band before accepting.
known host
Server presents the same key as stored. SSH verifies silently and proceeds. This is the normal case — you connect, no prompts.
key changed
Server presents a DIFFERENT key than stored. SSH shows a loud warning and refuses to connect:
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)!

Legitimate causes: server was reinstalled, key was rotated, or you are connecting via a different IP. Always investigate before bypassing.