ssh_exchange_identification: Connection closed by host
ssh_exchange_identification: Connection closed by remote host means the TCP connection to port 22 succeeded and then the other side closed it before the SSH identification exchange completed — so the failure is on the server or in the path, not in your key or password. On current OpenSSH clients (I reproduced this on OpenSSH 10.0p2) the same error prints as kex_exchange_identification: Connection closed by remote host; the old ssh_ prefix is what you will find in most Stack Overflow threads and on older clients. Same failure, same fixes.
I chased this one on a Friday deploy when three engineers lost SSH to the same box at once, and the usual answers — "you're banned by fail2ban", "raise MaxStartups" — turned out to be half right at best. The reason this error is so confusing is that one client-side message covers five completely different server-side stories. This post is the decision tree I now use: what the error proves, which layer actually closed the connection, and the exact fix for each cause.
TL;DR
- The error is post-handshake.
ssh_exchange_identificationfires only after TCP connect succeeds. A plain firewall DROP gives youConnection timed out; a REJECT gives youConnection refused. If you see this exact string, something accepted the connection and then killed it before the SSH banner exchange finished. - Run
ssh -vvvfirst.debug1: Connection established.followed immediately by the error = the server or a middlebox closed it. If you seedebug1: kex_exchange_identification: banner line 0: ...lines first, you are talking to something that is not sshd (an HTTP service, a proxy, a load balancer on the wrong port). - Check the server logs before touching sshd_config.
drop connection #N ... past MaxStartupsmeans OpenSSH's pre-auth connection limit throttled you — the classic cause under a botnet scan.Connection closed by ... [preauth]means the client side hung up. - fail2ban with its default action does NOT produce this error. The default
blocktypeisREJECT --reject-with icmp-port-unreachable→Connection refused. Only atcp-resetban action or an inline IPS that resets an in-flight connection produces the identification error. - TCP wrappers are a dead cause since 2014. OpenSSH 6.7 removed libwrap support;
/etc/hosts.denyadvice is obsolete on every modern distro build. - The durable fix is to stop exposing port 22 to the internet — SSH over Tailscale/WireGuard only. That eliminates the whole class (scanner floods filling pre-auth slots, IP bans, IPS resets) instead of tuning around it.
What the error actually tells you
SSH starts with the identification exchange: the client connects, both sides send a version line like SSH-2.0-OpenSSH_10.0, and only then does key exchange and authentication begin. If the server (or something in front of it) closes the TCP connection after accepting it but before that exchange completes, the client prints the identification error and exits 255.
I reproduced the exact client behavior locally with a listener that accepts and immediately closes a connection, then connected with maximum verbosity:
$ ssh -vvv -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=5 -o BatchMode=yes hermes@127.0.0.1
debug1: Connecting to 127.0.0.1 [127.0.0.1] port 2222.
debug1: Connection established.
kex_exchange_identification: Connection closed by remote host
Connection closed by 127.0.0.1 port 2222
Two things to note. First, Connection established. proves the three-way handshake completed — the port is reachable and something is accepting. Second, there is no debug1: Remote protocol version ... line, which is the tell: the server never sent its SSH banner. The close happened between accept and banner.
The prefix difference is cosmetic. OpenSSH renamed the internal function and the error label over the years; the current Debian/Ubuntu-era clients print kex_exchange_identification: while the historical ssh_exchange_identification: form lives on in search results and old guides. Both refer to the same exchange, so search with either.
Diagnostic tree: which side closed the connection
| What you see | Who closed it | Next step |
|---|---|---|
Connection established. then error, no banner line | Server policy (MaxStartups, config) or inline IPS reset | Server logs (below) |
banner line 0: HTTP/1.1 ... then error | You are talking to a non-SSH service | Check port translation / proxy / wrong IP |
banner line 0: Exceeded MaxStartups then error | OpenSSH MaxStartups throttle | Raise MaxStartups / stop the flood |
Hangs, then Connection timed out | Firewall DROP (security group, nftables, fail2ban DROP) | Check egress/ingress rules; unban IP |
Instant Connection refused | Firewall REJECT, port closed, sshd not listening | Check sshd is up; check REJECT rules |
Start on the client. The -vvv output above tells you which row of the table you are in before you touch the server. If the connection closes instantly and consistently, it is almost always policy on the server; if it is intermittent, look for rate limiting or a scanning flood.
Then go to the server and read the logs. On systemd distros:
sudo journalctl -u ssh -u sshd --since "10 minutes ago" | grep -Ei "drop connection|MaxStartups|preauth|refused connect"
# Debian/Ubuntu without journald:
sudo grep -Ei "drop connection|MaxStartups|preauth|refused connect" /var/log/auth.log
The two log lines that matter, with the wording verified in the OpenSSH 8.x/9.x source:
sshd[1234]: drop connection #7 from [192.0.2.10]:51234 on [198.51.100.5]:22 past MaxStartups— sshd throttled the connection because too many unauthenticated connections are open. This is cause #1 below.sshd[1234]: Connection closed by 192.0.2.10 port 51234 [preauth]— the client closed the connection during pre-auth. If this matches your attempts, the server accepted you fine; the close is client-side or in the path. (Same forensics as a web server logging a close before the request finished — see my Nginx 499 "client closed request" analysis.)
Next, count how many unauthenticated connections are parked on port 22. A botnet doing full TCP handshakes (not just SYN scans) can fill every pre-auth slot and throttle everyone else:
ss -tn state syn-recv
ss -tnp 'sport = :22' | head -20
ss -tn state established '( sport = :22 )' | wc -l
If you see dozens of entries from random IPs, you are under a handshake flood and MaxStartups is doing its job — badly, because it is throttling you too. Finally, check whether your IP is banned and how:
sudo fail2ban-client status sshd
sudo iptables -L f2b-sshd -n -v
sudo nft list ruleset | grep -A 8 "f2b-sshd"
If your IP appears in the f2b chain, look at the target: REJECT --reject-with icmp-port-unreachable (default) explains a Connection refused, not this error. Keep reading.
Cause 1: MaxStartups — the pre-auth connection limit
MaxStartups caps concurrent unauthenticated connections to sshd. Per the man page, the default is 10:30:100: throttling starts at 10 unauthenticated connections, the drop probability ramps linearly from 30% at 10 up to 100% at 100. Connections past the limit are refused — in modern OpenSSH the server logs drop connection #N ... past MaxStartups and even sends the client a Exceeded MaxStartups banner line before closing. Either way, the client ends up with the identification error.
This is the single most common cause, and it is usually triggered by something else: a credential-stuffing botnet that completes TCP handshakes (many scanners do, to look like real clients), a misbehaving monitoring job, or a CI runner that opens dozens of connections and never authenticates. Each of those occupies a pre-auth slot until it gives up or LoginGraceTime (default 120 seconds) expires.
Fix the trigger first, then raise the limit if you genuinely need headroom. In /etc/ssh/sshd_config:
# /etc/ssh/sshd_config
# start throttling at 30, ramp to 100% by 100 — headroom for legitimate concurrency
MaxStartups 30:50:100
# cap any single source (OpenSSH 8.2+; default is "none" = unlimited)
PerSourceMaxStartups 5
sudo sshd -t && sudo systemctl reload ssh
Raising MaxStartups increases exposure during an attack, so prefer the two-line combo: keep the global limit reasonable and use PerSourceMaxStartups so one abusive IP cannot consume the whole pool. If the flood is the problem, the permanent fix is the last section of this post.
Cause 2: fail2ban, sshguard, CrowdSec — and why the default does not match
Most guides blame IP bans for this error, and it is the wrong first guess more often than not. A ban implemented as a firewall REJECT or DROP happens before the SSH handshake, so the client never reaches the identification exchange and you get a different message entirely:
| Ban action | Client message |
|---|---|
REJECT --reject-with icmp-port-unreachable (fail2ban default blocktype) | ssh: connect to host X port 22: Connection refused |
DROP (some setups, most cloud security groups) | ssh: connect to host X port 22: Connection timed out |
REJECT --reject-with tcp-reset (custom actions) | RST on an in-flight connection → can surface as the identification error |
The fail2ban default is verified in its shipped action file (iptables.conf): blocktype = REJECT --reject-with icmp-port-unreachable. So if you see ssh_exchange_identification, the first thing to check is not whether you are banned — it is which blocktype you are banned with. If someone customized banaction or the blocktype to tcp-reset, an established-but-preauth connection gets an RST and the client reports the identification error. That is the fail2ban case that does match this error string.
If your IP is banned with the default REJECT, unban it (sudo fail2ban-client set sshd unbanip YOUR_IP) and then fix the retry storm that got you banned — usually password-auth scanning, which journalctl -u ssh will show as repeated Failed password lines. Switching to key-only auth (PasswordAuthentication no) plus MaxAuthTries 3 removes the noise at the source.
Cause 3: an inline IPS or a non-SSH service on the other end
When the server logs show nothing — no drop connection, no preauth close for your attempts — the close is happening somewhere in the path. Two patterns show up constantly in the field:
An inline IPS or cloud WAF resets the connection. Some intrusion-prevention appliances and managed WAFs are configured to RST suspicious SSH handshakes (unusual client banners, port scans, geo-policy hits). Because the reset lands after the TCP handshake, the client reports exactly this error. Tell-tale: it happens from some source IPs/networks and not others, and the server sees the connection accepted and then reset. Whitelist your management IP or move SSH off the public internet (below).
The port is translated or proxied to a non-SSH service. A load balancer forwarding the SSH port to an HTTP backend, a Docker host where the port is taken by another container, or an internal port-translation rule pointing at the wrong service. The -vvv output gives it away immediately — you will see HTTP response lines where the SSH banner should be:
debug1: kex_exchange_identification: banner line 0: HTTP/1.1 400 Bad Request
debug1: kex_exchange_identification: banner line 1: Server: nginx/1.14.0
kex_exchange_identification: Connection closed by remote host
This exact case is documented in the long-running Server Fault thread on this error: the fix was correcting internal port translation, not touching sshd at all. Verify what is actually listening on the destination IP:port from a machine in the same network — nmap -p 22 --reason TARGET (needs root for the SYN scan; see my Nmap "requires root privileges" fix for the exact invocation) tells you whether port 22 is open, filtered, or answered by something that is not SSH.
The dead cause: TCP wrappers and /etc/hosts.deny
Older threads will tell you to check /etc/hosts.deny and add sshd: ALL. That advice has been obsolete since 2014: OpenSSH 6.7's release notes state plainly, "Support for tcpwrappers/libwrap has been removed." No current distro build of sshd consults /etc/hosts.deny at all, so a refused connect from log line is a fossil, not a live cause. Do not waste time on it — and if you still run a 2013-era server that honors libwrap, the fix is to migrate off it, not to tune it.
The durable fix: stop exposing port 22 to the internet
Every cause in this post — scanner floods filling pre-auth slots, IP bans, IPS resets — assumes port 22 is reachable from the internet. Remove that assumption and the whole class disappears. The zero-open-port pattern fits SSH better than any other service: bind sshd to the private interface only and reach it through an overlay network.
# 1. Tailscale up (or WireGuard), then firewall the public path:
sudo ufw allow in on tailscale0 to any port 22
sudo ufw deny 22/tcp
# or nftables/iptables equivalent: allow 22 only from the overlay subnet
# 2. sshd listens only on the private/overlay interface:
ListenAddress 100.101.102.103 # your tailscale0 IP
ListenAddress 10.0.0.5 # LAN IP if you still need LAN access
After a reload, ssh user@tailscale-ip works over an encrypted, authenticated tunnel; nothing on the public interface answers on 22; and the ssh_exchange_identification error from scanners becomes irrelevant. If you see SSL handshake errors while setting the same pattern up for HTTPS, the same "who is actually answering" logic applies as in my Tailscale SSL_ERROR_RX_RECORD_TOO_LONG post.
Verification checklist
- Client side:
ssh -vvvnow showsdebug1: Remote protocol version 2.0 ...followed by host key and auth — the identification exchange completed. Exit code 0 (or auth failure, not connection failure). - Server logs: no new
drop connection ... past MaxStartupslines for your source;ss -tn state syn-recvis quiet or shows only the flood you still need to block. - If you were banned:
sudo fail2ban-client status sshdno longer lists your IP, and the client message changed from the identification error to a normal auth flow. - If a middlebox was involved: the same client from a whitelisted network connects cleanly, and the IPS rule that matched your source is identified.
- If you went zero-open-port:
sudo nmap -p 22 PUBLIC_IPshowsfilteredor no response, whilessh tailscale-ipconnects.
When a connection is closed before the banner, treat it as a network-forensics problem, not an authentication problem: the error string tells you the TCP path worked, so the answer is in sshd's logs, the firewall, or the path itself — in that order. Once you find which side closed it, the fix is usually five minutes.