AWS Compute

EC2 SSH 'Connection Timed Out' & 'Permission Denied': The Complete Troubleshooting Guide

Quick take: EC2 SSH fails in exactly three ways, and they are not interchangeable. Connection timed out means your packet never got an answer — a network/path problem (security group, NACL, route/IGW, no public IP, OS firewall). Connection refused means the host answered but nothing is listening on the port — sshd is down, on another port, or still booting. Permission denied (publickey) means you reached sshd and it rejected your authwrong key, wrong username for the AMI, or bad file permissions. Read the exact error string first; it tells you which third of this guide you are in. Guessing wastes the incident; the string is the diagnosis.

It is 09:40, the deploy pipeline is red, and you cannot get onto the box to see why. ssh -i deploy.pem ec2-user@52.14.x.x just… hangs, then prints ssh: connect to host 52.14.x.x port 22: Connection timed out. Your colleague swears it worked yesterday. Nothing changed — except your home ISP handed you a new IP overnight, the My IP rule in the security group still pins last night’s address, and every SYN packet you send is now silently discarded at the ENI. Ninety seconds of reading the right signal would have told you that. Instead the team is about to widen the security group to 0.0.0.0/0, reboot the instance, and open a support case — three wrong moves for one five-second fix.

This is the most common EC2 support problem in the world, and it is maddening precisely because the same action (ssh) produces three different failures with three different root-cause families and three different fixes. The discipline that separates a two-minute recovery from a two-hour one is triage: name the failure class from the exact error string before you touch anything. A timeout and a refusal look identical in your terminal history a day later, but they are as different as “the road is closed” versus “the shop is shut.” This guide is built around that split. We trace one SSH connection through every hop — client → internet → IGW → route table → NACL → security group → ENI → sshd → authentication — map each of the three failure classes onto the exact hop where it bites, then spend the bulk of the article on a structured playbook: decode tables, a 20-row symptom → category → root cause → confirm → fix matrix, an ordered “checks in order” runbook, and the escape hatches for when SSH is dead entirely — EC2 Serial Console, EC2 Instance Connect, and SSM Session Manager, which needs no port 22 and no public IP at all.

By the end you will stop guessing. When the terminal says timed out you will look at the path; when it says refused you will look at the daemon; when it says Permission denied (publickey) you will look at the key and the username — and you will confirm each with an exact command before you change anything. Every operation shows both the aws CLI and Terraform, and the whole thing maps to the compute and networking domains of SAA-C03 and SOA-C02, where “why can’t I SSH in” is a near-guaranteed question.

What problem this solves

SSH to EC2 hides a surprising amount of machinery behind a one-line command, and when any layer of it misbehaves the feedback you get is deliberately vague. A firewall that drops your packet cannot, by design, send back “I dropped your packet” — that would help attackers map your network — so it sends nothing, and your client waits until the TCP handshake times out. The information you need is real and knowable, but it is scattered across the security group, the NACL, the route table, the instance’s public-IP assignment, the OS firewall, sshd’s config, the key’s file permissions, and the AMI’s default username. Not knowing which layer maps to which symptom is what turns a trivial fix into an afternoon.

What breaks without this triage discipline is a predictable, wasteful pattern. An engineer sees a timeout and restarts the instance (which sometimes “fixes” it by chance because a stop-start re-runs user-data or clears an OS-firewall state, teaching the wrong lesson). Another opens the security group to 0.0.0.0/0:22 — turning a firewall into an open door — because the timeout “went away.” A third burns an hour re-reading sshd config for a Permission denied that was simply the wrong username, root@ instead of ec2-user@. A fourth stares at Connection refused and blames the security group, when a refusal proves the security group is fine — the packet reached a host that answered. Each of these is a concept you learn once and never re-pay.

Who hits this: everyone who runs EC2. It bites hardest on first-time launchers (wrong AMI username, forgotten key, chmod on the .pem), on teams behind corporate networks (egress port 22 blocked at the office firewall), on anyone with a dynamic home IP (the My IP rule goes stale nightly), on private-subnet workloads (no path from the internet at all — the entire reason SSM Session Manager exists), and on modern clients hitting old servers (OpenSSH 8.8+ disabled the legacy ssh-rsa SHA-1 signature by default, and a valid old key suddenly gets Permission denied). The model is closed and small: three error classes, one connection path, a handful of hops. Learn the path and the return-traffic rules and the fog clears.

Here is the whole field in one frame — the three classes, what each proves, and where to look first:

Error class (exact string) What it PROVES What it does NOT prove First place to look
Connection timed out Nothing answered your SYN That sshd is down (you never reached it) Security group → route/public IP → NACL → OS firewall
Connection refused A host answered with RST That the network is broken (packet got through) sshd running? right port? still booting? fail2ban?
Permission denied (publickey) You reached sshd; auth failed That the key is wrong (could be the username) Username for the AMI → correct key → key/dir permissions
No route to host The local/edge network has no path A server-side problem Local routing, VPN, subnet route table, IGW
Host key verification failed The host key changed vs known_hosts An AWS-side fault Re-launched instance reusing an IP; clean known_hosts

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should be able to launch an EC2 instance, know what a key pair, security group, subnet, route table and internet gateway (IGW) are, and run the AWS CLI with credentials (aws sts get-caller-identity returns your account). You need an SSH client — OpenSSH on macOS/Linux/WSL, or a recent Windows 10/11 which ships ssh.exe. Basic Linux process and permission concepts (systemctl, chmod, file ownership) help for the server-side fixes.

This sits at the intersection of Compute and Networking troubleshooting. It assumes the firewall mechanics covered in depth in Security Groups vs NACLs: A Deep Dive and Troubleshooting Guide — this article reuses the stateful vs stateless return-traffic rule but focuses it on port 22. It is the compute-side companion to the broader AWS VPC Connectivity Troubleshooting Playbook, and the natural next read after you first Launch Your First EC2 Instance and SSH In — that walkthrough builds the working case; this one fixes it when it breaks.

A quick ownership map so you page the right person during an incident:

Layer What lives here Who usually owns it Failure class it causes
Client / corporate network Local firewall, VPN, egress rules You / IT / NetSec Timeout (egress 22 blocked), No route to host
DNS / public IP Public IPv4, EIP, public DNS name App / platform Timeout (stale or missing IP)
Route table + IGW 0.0.0.0/0 → igw, subnet association Network team Timeout (no path from internet)
NACL (subnet) Stateless in/out rules, ephemeral range Network team Timeout (in 22 or out ephemeral missing)
Security group (ENI) Stateful inbound tcp 22 ← your IP App / platform Timeout (#1 cause)
EC2 / ENI state Running? public IP attached? 2/2 checks Platform Timeout / refused (stopped, impaired)
sshd (in guest) Daemon, Port, listen address, fail2ban App / OS Refused (down, wrong port), denied (config)
Auth (key + user) authorized_keys, permissions, username App / OS Permission denied (publickey)

Core concepts

Five mental models make every later diagnosis obvious.

The error string is the triage. ssh reports the first layer that failed and stops. A timeout means the TCP three-way handshake never completed — your SYN went out and no SYN-ACK came back, because something on the path dropped it silently (firewalls drop, they do not reply). A refusal means a host did answer, with a TCP RST — the machine is up and reachable, but no process is listening on that port (or something actively rejected the SYN). A Permission denied means the TCP connection succeeded, the SSH protocol negotiated, and then authentication failed. These three sit at three different depths of the same connection, and each rules the earlier layers in: a refusal proves your security group, route, and public IP are all correct, because the packet reached a listening host to be refused.

Firewalls are silent; applications are loud. A security-group or NACL drop produces a timeout — no ICMP, no RST, nothing. An in-guest iptables -j DROP also times out. But an in-guest iptables -j REJECT, a stopped sshd, or fail2ban’s default action can produce a refusal. So “timeout vs refused” is often “packet dropped before the OS vs packet reached the OS.” This single distinction eliminates half the search space instantly.

The path is asymmetric and the NACL is stateless. Inbound, your packet crosses NACL-in → SG-in → ENI. The reply retraces SG-out → NACL-out. The security group is stateful — allow the inbound SSH and the reply is automatic. The NACL is stateless — it must explicitly allow the outbound reply, which leaves from the instance’s ephemeral port (1024–65535) back to your client. A custom NACL that allows inbound 22 but forgets the outbound ephemeral range drops every reply, and you see a timeout even though “the rule for 22 is right there.” (This is the classic covered in the SG-vs-NACL deep dive; on port 22 it is just as common.)

Reachability is not just firewalls — it is having a path at all. Even with perfect firewall rules, a packet from the internet reaches your instance only if the instance has a public IPv4 address or Elastic IP, sits in a subnet whose route table sends 0.0.0.0/0 to an internet gateway, and is actually running. A private-subnet instance has no such path by design — you must reach it through a bastion, a VPN, an EC2 Instance Connect Endpoint, or SSM Session Manager. And a public IP that is auto-assigned (not an EIP) changes on every stop-start, so yesterday’s DNS name or IP silently points at nothing.

Authentication is a contract with three terms: the key, the user, and the permissions. sshd accepts you only if (1) you present the private key whose public half is in that user’s ~/.ssh/authorized_keys, (2) you log in as the correct OS user for the AMI (Amazon Linux ec2-user, Ubuntu ubuntu, Debian admin, …), and (3) the key file on your side and ~/.ssh on the server side have tight enough permissions that sshd’s StrictModes trusts them. Miss any one and you get the same Permission denied (publickey) — which is why the message alone never tells you which term failed, and why ssh -vvv is mandatory.

The vocabulary in one table

Term One-line definition Where it lives Why it matters to SSH
Key pair Public/private RSA/ed25519 pair; AWS keeps the public half Your .pem + EC2 metadata Wrong/lost private key → Permission denied
Security group Stateful, allow-only firewall on the ENI Attached to the ENI No inbound 22 rule → timeout (#1)
NACL Stateless, ordered allow+deny firewall on the subnet Subnet Missing in-22 or out-ephemeral → timeout
Route table Where 0.0.0.0/0 and local traffic go Associated to the subnet No → igw → timeout from internet
Internet gateway (IGW) The VPC’s door to the internet Attached to the VPC Absent/unrouted → no public path
Public IPv4 / EIP The internet-routable address on the ENI ENI None → unreachable; auto-IP changes on stop-start
sshd The SSH daemon listening on :22 In the guest OS Down/other port → refused
authorized_keys The user’s list of allowed public keys ~/.ssh/authorized_keys Key absent/wrong perms → denied
StrictModes sshd’s refusal to trust loose permissions sshd_config (default yes) Loose ~/.ssh → denied despite right key
Ephemeral ports The high ports a reply leaves from 1024–65535 NACL must allow them outbound
SSM Agent Systems Manager agent for session/automation In the guest + instance profile Enables Session Manager (no port 22)
Status checks System (2/2) + instance reachability probes EC2 console / API 1/2 or 0/2 → boot/OS problem → refused/timeout

The triage: timed out vs refused vs denied

Everything starts here. Run the connection verboselyssh -vvv -i key.pem user@host — and read the last thing it prints before it fails. That single line places you in one of three columns. Do not proceed to any fix until you have named the column.

Signal Connection timed out Connection refused Permission denied (publickey)
TCP handshake Never completes (SYN, no reply) Completes then RST, or immediate RST Completes fully
Speed Slow — hangs ~20–75 s then fails Fast — fails in <1 s Fast — negotiates then rejects
ssh -vvv last line Connecting to host … port 22. then silence connect to address … port 22: Connection refused Offering public key … → server declines
Layer that failed Network / path (before the OS) Reached the OS; port closed Reached sshd; auth failed
Rules IN as correct Nothing yet proven SG, NACL, route, public IP all OK Everything through TCP is OK
Root-cause family SG, NACL, route/IGW, public IP, OS -j DROP, corp egress sshd down/other port, still booting, fail2ban, -j REJECT Wrong user, wrong key, key not authorized, perms, ssh-rsa
First confirm Reachability Analyzer / describe-security-groups Serial Console systemctl status sshd ssh -vvv + check the AMI username
First fix Open 22 from your IP / add route / assign IP Start sshd / use right port / wait Right user@, right -i key, fix perms

Two reading notes that save the most time. First, macOS prints Operation timed out where Linux prints Connection timed out — same meaning. Second, a Connection reset by peer or kex_exchange_identification: Connection closed by remote host is a fourth, subtler signal: the TCP connection opened but sshd hung up during the banner/key exchange — almost always fail2ban/sshguard, MaxStartups throttling, TCP wrappers, or an sshd mid-restart. Treat it as “reached the daemon, daemon rejected the session” — closer to the refused/denied side than the timeout side.

Class 1 — Connection timed out (network / path)

A timeout means your packet died on the way in. Work the hops outside-in, cheapest check first. The order matters: 80% of real timeouts are the security group or a stale source IP, so start there.

# Root cause Why it times out Confirm (exact) Fix
T1 No inbound SG rule for 22 SG is allow-only; absent rule = silent drop aws ec2 describe-security-groups --group-ids sg-x --query "SecurityGroups[].IpPermissions" authorize-security-group-ingress --port 22 --cidr <you>/32
T2 Stale My IP / dynamic ISP / VPN Rule pins an old /32; your IP changed Compare curl -s https://checkip.amazonaws.com to the rule’s CIDR Update the rule to your current /32
T3 Wrong SG attached to the ENI Rule is perfect but on an unattached SG describe-instances … NetworkInterfaces[].Groups Attach the correct SG to the ENI
T4 No public IPv4 / EIP No internet-routable address to hit describe-instances … Association.PublicIp is null Associate an EIP, or enable auto-assign + relaunch
T5 No route 0.0.0.0/0 → igw Subnet has no path to the internet describe-route-tables for the subnet’s assoc Add --destination-cidr-block 0.0.0.0/0 --gateway-id igw-x
T6 Private subnet (by design) Never meant to be internet-reachable Subnet’s route table has no IGW route Use a bastion, EIC Endpoint, or SSM Session Manager
T7 Instance stopped / terminated Nothing is there to answer describe-instances … State.Name Start it; note the public IP will change (no EIP)
T8 Stale public DNS/IP after stop-start Auto-assigned IP rotates on stop-start Compare current PublicIpAddress to what you dialled Reconnect to the new IP; use an EIP to pin it
T9 Custom NACL denies inbound 22 Stateless subnet firewall blocks entry describe-network-acls … Entries for an in-deny Add an inbound ALLOW for tcp 22 below the deny
T10 NACL missing outbound ephemeral Reply can’t leave; you never see SYN-ACK NACL egress lacks 1024–65535; Flow Logs REJECT Add outbound ALLOW tcp 1024-65535 to your CIDR
T11 OS firewall -j DROP iptables/ufw/firewalld drops the SYN in-guest Serial Console: ufw status / iptables -L -n Allow 22 in the OS firewall (or disable for triage)
T12 Corporate egress blocks 22 Your office/VPN blocks outbound 22 ssh -vvv hangs from office, works from phone hotspot Use SSM Session Manager over 443, or a VPN exception
T13 Wrong region/subnet target You’re dialling an IP that isn’t this box describe-instances region + IP sanity check Target the right instance/region
T14 IPv6-only client, no ::/0 rule v6 client, SG/NACL only has v4 rules ssh -4 works, ssh -6 times out Add an inbound rule for your IPv6 /128

Because so many timeouts are the inbound rule, here is that rule field by field — the exact shape of a correct SSH ingress and where each field goes wrong:

Field Value for SSH Example Why / gotcha
Protocol tcp --protocol tcp -1/all works but is over-broad; SSH is TCP only
Port range 22 (from=to=22) --port 22 A range like 0-65535 is the open-door anti-pattern
Source (CIDR) your /32 203.0.113.5/32 0.0.0.0/0 exposes SSH to the whole internet
Source (SG ref) a bastion’s SG --source-group sg-bastion Preferred for tiered access; no IP to go stale
Source (prefix list) EC2_INSTANCE_CONNECT a managed prefix list Needed if you use browser EC2 Instance Connect
IPv6 source your /128 2406:da00::/128 Separate rule; a v4-only rule drops v6 clients
Description who / why "me-home" No functional effect, but saves the next engineer

And the reason the source keeps going stale — where your public IP actually comes from and how stable it is:

Your network Source IP AWS sees Stability SSH implication
Home broadband ISP-assigned public IP Rotates (days–weeks) My IP rule goes stale → timeout
Office (static) Fixed egress IP Stable Safe to pin a /32
Corporate VPN VPN concentrator IP Stable per POP Pin the VPN egress, not your laptop
CGNAT / mobile Shared carrier IP Volatile, shared Pin a wider range, or use SSM
Cloud / CI runner The runner’s NAT/EIP Varies per run Use an SSM/OIDC path, not a pinned IP
Behind a NAT gateway The NAT’s EIP Stable if EIP Pin the NAT EIP for egress SSH

The one-line proofs

# Your current public IP (the value the SG rule must contain)
curl -s https://checkip.amazonaws.com

# Does the instance even have a public address, and is it running?
aws ec2 describe-instances --instance-ids i-0abc123 \
  --query "Reservations[].Instances[].{State:State.Name,Pub:PublicIpAddress,Subnet:SubnetId,SGs:SecurityGroups[].GroupId}" \
  --output table

# Does the subnet route 0.0.0.0/0 to an IGW?
aws ec2 describe-route-tables \
  --filters Name=association.subnet-id,Values=subnet-0abc \
  --query "RouteTables[].Routes[?DestinationCidrBlock=='0.0.0.0/0']"

# Is there an inbound rule for tcp 22, and from which CIDR?
aws ec2 describe-security-groups --group-ids sg-0abc \
  --query "SecurityGroups[].IpPermissions[?FromPort==\`22\`]"

The fastest deterministic answer is Reachability Analyzer — it evaluates SG + NACL + route + IGW together and names the blocking component:

PATH_ID=$(aws ec2 create-network-insights-path \
  --source igw-0abc --destination i-0abc123 \
  --protocol tcp --destination-port 22 \
  --query NetworkInsightsPath.NetworkInsightsPathId --output text)

ANALYSIS=$(aws ec2 start-network-insights-analysis \
  --network-insights-path-id "$PATH_ID" \
  --query NetworkInsightsAnalysis.NetworkInsightsAnalysisId --output text)

aws ec2 describe-network-insights-analyses \
  --network-insights-analysis-ids "$ANALYSIS" \
  --query "NetworkInsightsAnalyses[].{Reachable:NetworkPathFound,Blocker:Explanations[0].ExplanationCode}"

NetworkPathFound: false with an ExplanationCode like ENI_SG_RULES_MISMATCH or NO_ROUTE_TO_DESTINATION points straight at the layer. (Each analysis costs a small per-run fee — see Cost & sizing.)

Class 2 — Connection refused (reached the host, port closed)

A refusal is good news for the network: the packet reached a live host that answered. The problem is now on or inside the instance. Because SSH is dead you will usually confirm these via Serial Console or SSM Session Manager, not SSH.

# Root cause Why it refuses Confirm (exact) Fix
R1 sshd not running / crashed No process bound to :22 → RST Serial Console: systemctl status sshd (or ssh) systemctl start sshd && systemctl enable sshd
R2 sshd on a non-standard port Someone set Port 2222 Serial Console: ss -tlnp | grep sshd ssh -p 2222 …, and open that port in the SG
R3 Instance still booting sshd not up yet in the first ~30–90 s Status checks not yet 2/2 Wait; watch describe-instance-status
R4 fail2ban / sshguard ban Repeated fails → REJECT your IP Serial Console: fail2ban-client status sshd fail2ban-client set sshd unbanip <you>
R5 OS firewall -j REJECT In-guest REJECT sends an RST Serial Console: iptables -L -n | grep 22 Change REJECT→ACCEPT for 22, or fix the rule
R6 Disk full → sshd can’t fork No space to create session/PTY Serial Console: df -h shows 100% Clear space (journalctl --vacuum, logs, /tmp)
R7 sshd config broke; failed to start Bad sshd_config after an edit Serial Console: sshd -t prints the syntax error Fix the config line; systemctl restart sshd
R8 Socket unit disabled ssh.socket masked/disabled Serial Console: systemctl status ssh.socket systemctl enable --now ssh
R9 TCP wrappers hosts.deny Legacy /etc/hosts.deny: sshd: ALL Serial Console: read `/etc/hosts.allow deny`
R10 Wrong port on the client You passed -p for the wrong port Re-run without -p, or with the real one Use the correct port

Most of R1–R7 come down to sshd’s own config, so keep this directive reference at hand. Read the effective config on the box with sshd -T (it resolves includes and defaults), never the raw file:

Directive Default Effect if misset Confirm (on host)
Port 22 Non-22 → client refused on :22 sshd -T | grep -i '^port'
ListenAddress all interfaces Bound to 127.0.0.1 → refused externally sshd -T | grep -i listenaddress
PubkeyAuthentication yes no → every key login denied sshd -T | grep -i pubkey
PasswordAuthentication no on AWS AMIs no + no valid key → denied sshd -T | grep -i passwordauth
PermitRootLogin prohibit-password noroot@ denied (use the AMI user) sshd -T | grep -i permitroot
AllowUsers / AllowGroups unset Set without your user → denied sshd -T | grep -iE 'allow(users|groups)'
DenyUsers / DenyGroups unset Lists your user → denied sshd -T | grep -i deny
StrictModes yes Loose ~/.ssh perms → denied sshd -T | grep -i strictmodes
MaxAuthTries 6 Low + many agent keys → Too many failures sshd -T | grep -i maxauthtries
MaxStartups 10:30:100 Low under load → kex … closed sshd -T | grep -i maxstartups
PubkeyAcceptedAlgorithms 8.8+ excludes ssh-rsa Excludes your key’s algorithm → denied sshd -T | grep -i acceptedalgo

If the refusal is a brute-force defence biting your own IP (R4), know which tool is running and how to clear yourself:

Tool Trigger Ban action Check Unban
fail2ban Failed logins in a window iptables/nftables drop or reject fail2ban-client status sshd fail2ban-client set sshd unbanip <you>
sshguard Repeated auth failures Firewall block journalctl -u sshguard Flush its table / whitelist your IP
DenyHosts (legacy) Failed logins /etc/hosts.deny entry Read /etc/hosts.deny Remove your IP; add to hosts.allow

The tell that separates R1–R2 from a genuine boot problem is the status checks. A refusal with 2/2 checks passing means the OS is healthy but the daemon or a firewall is the issue (R1, R2, R4, R5). A refusal with 1/2 (instance check failing) means the OS itself is unhealthy — kernel panic, corrupt fstab, misconfigured network — and you go to the Serial Console to read the boot messages:

aws ec2 describe-instance-status --instance-ids i-0abc123 \
  --query "InstanceStatuses[].{System:SystemStatus.Status,Instance:InstanceStatus.Status}"
# {"System":"ok","Instance":"impaired"}  → OS-level problem, open Serial Console

Read the two checks together — the pair localises the fault to AWS, the OS, or just “still booting”:

System check Instance check Means Action
ok ok (2/2) The OS is healthy Refused/denied is sshd/auth, not the OS
ok impaired (1/2) Guest OS / boot problem Serial Console → read boot log, fstab, network
impaired ok / insufficient AWS host / hardware issue Stop-start (moves to a new host); check Health
insufficient-data insufficient-data Still booting or agent not reporting Wait, then re-check
n/a initializing First ~1–3 min after launch Expect transient refused; wait

Class 3 — Permission denied (publickey) (reached sshd, auth failed)

You are through the network and the daemon is up. The three-term contract failed. This class has the widest fan-out and the message never says which term — ssh -vvv does.

# Root cause Why auth fails Confirm (exact) Fix
P1 Wrong username for the AMI Key is right; user has no such authorized_keys -vvv: offers key, server declines for that user Use the AMI’s user (table below)
P2 Wrong private key Not the pair AWS injected at launch -vvv: Offering public key then decline ssh -i <correct>.pem; check the launch key name
P3 Key never placed Launched with no key, or key removed Instance’s KeyName is empty in metadata Re-inject via user-data / EIC / AWSSupport-ResetAccess
P4 Local key perms too open Client refuses to use a world-readable key WARNING: UNPROTECTED PRIVATE KEY FILE! chmod 400 key.pem
P5 Server ~/.ssh perms (StrictModes) sshd won’t trust a loose ~/.ssh/home Serial Console: ls -ld ~/.ssh is 0777/group-writable chmod 700 ~/.ssh; chmod 600 authorized_keys
P6 Wrong ownership of authorized_keys Owned by root, not the user Serial Console: ls -l ~/.ssh/authorized_keys chown user:user ~/.ssh -R
P7 Disk full Can’t read/write .ssh or PTY Serial Console: df -h = 100% Free space; retry
P8 SELinux context wrong ~/.ssh mislabeled after a restore/edit Serial Console: ls -Z ~/.ssh; ausearch -m avc restorecon -R -v ~/.ssh
P9 PubkeyAuthentication no Key auth disabled in sshd Serial Console: sshd -T | grep pubkey Set PubkeyAuthentication yes; restart sshd
P10 AllowUsers/DenyUsers excludes you Your user isn’t allowed Serial Console: sshd -T | grep -i allowusers Add your user; restart sshd
P11 Account locked / nologin shell User exists but can’t log in Serial Console: passwd -S user; getent passwd Unlock; set a real shell
P12 Too many keys offered Agent offers wrong keys → Too many auth failures -vvv: several Offering before disconnect ssh -o IdentitiesOnly=yes -i key.pem …
P13 ssh-rsa disabled (OpenSSH 8.8+) New client/server rejects SHA-1 RSA -vvv: send_pubkey_test: no mutual signature algorithm -o PubkeyAcceptedAlgorithms=+ssh-rsa, or new ed25519 key
P14 Wrong key format (PuTTY .ppk) .ppk isn’t OpenSSH format Load key … invalid format Convert with puttygen, or use -i OpenSSH .pem

The AMI username you must use (P1 — the single most common)

root@ almost never works; each AMI ships a default sudo-capable user. Using the wrong one produces Permission denied (publickey) with the correct key, which sends people down the wrong path for hours.

AMI / distribution Default SSH username Fallback / note
Amazon Linux 2 / 2023 ec2-user
Ubuntu (all versions) ubuntu
Debian admin older images root
RHEL 8/9 ec2-user older root
CentOS / CentOS Stream centos some community AMIs ec2-user
Rocky / AlmaLinux rocky / almalinux some use ec2-user
SUSE / SLES ec2-user older root
Fedora fedora some ec2-user
FreeBSD ec2-user some root
Bitnami (any stack) bitnami
Windows (RDP, not SSH) Administrator password via key decrypt

If you do not know the AMI, ssh -vvv shows Authentications that can continue: publickey, you offer the key, and the server declines — try each documented user, or read the AMI’s marketplace/vendor page. A generic probe: many AMIs print the right user in the SSH banner error, and cloud-init logs it in /var/log/cloud-init.log (readable via Serial Console).

The permission contract (P4–P6) in exact numbers

sshd’s StrictModes yes (the default) refuses a key if the surrounding permissions are too loose — even when the key itself is correct. These are the exact modes it demands:

Path Required mode Owner Symptom if wrong
Private key (key.pem, client) 0400 or 0600 you UNPROTECTED PRIVATE KEY FILE! → key ignored → denied
~/.ssh (server) 0700 the login user Denied despite correct key (StrictModes)
~/.ssh/authorized_keys (server) 0600 (or 0644) the login user Denied; sshd distrusts a group/world-writable file
Home dir ~ (server) not group/world-writable (0755/0750) the login user Denied; a 0777 home fails StrictModes
/var/empty/sshd (privsep) 0755, root-owned root sshd fails to start → refused

One more denial family hides in the key itself — its type and format. A .ppk is not an OpenSSH key, and an old ssh-rsa SHA-1 key is refused by modern daemons:

Key type / format Typical file Works with Note
RSA (SHA-2) .pem Everything modern Fine on OpenSSH 8.8+ using SHA-2 signatures
RSA (SHA-1 ssh-rsa) .pem Pre-8.8 servers only Denied by default on 8.8+ (P13)
ed25519 id_ed25519 OpenSSH 6.5+ Preferred; sidesteps the 8.8 issue entirely
ECDSA .pem Broad Acceptable; ed25519 preferred
OpenSSH PEM (-----BEGIN …) .pem ssh -i directly AWS create-key-pair default output
PuTTY .ppk .ppk PuTTY / Pageant only ssh -i fails invalid format; convert with puttygen

Reading ssh -vvv — the single most useful skill

ssh -vvv prints every step. You do not read all of it; you read the last few lines before failure and match them to the class. This table is the decoder:

-vvv line (last meaningful one) It means Class
Connecting to 52.x.x.x [52.x.x.x] port 22. then long silence SYN sent, nothing back Timeout (network)
connect to address 52.x.x.x port 22: Connection refused Host answered RST Refused (port)
connect to address 52.x.x.x port 22: Connection timed out Path drop Timeout
kex_exchange_identification: Connection closed by remote host sshd hung up at banner fail2ban / MaxStartups / restart
Authentications that can continue: publickey → decline Server rejected your key/user Denied
Offering public key: key.pemwe sent a publickey packet, wait for reply → decline That key not accepted for this user Denied (P1/P2)
Server accepts key then still denied Key OK but StrictModes/perms on server Denied (P5/P6)
send_pubkey_test: no mutual signature algorithm ssh-rsa/SHA-1 deprecation Denied (P13)
Received disconnect … Too many authentication failures Agent offered too many keys Denied (P12)
Load key "key.pem": invalid format Not an OpenSSH private key Denied (P14)
Host key verification failed. Host key changed vs known_hosts Not auth — clean known_hosts

The most valuable single distinction: Offering public key … [decline] puts you in P1/P2 (wrong key or wrong user — the auth was attempted and refused), whereas Server accepts key then denied puts you in P5/P6 (the key is right, so it must be permissions/StrictModes on the server). That one fork saves the most time in the whole guide.

These are the client flags you actually reach for during triage — each one isolates or works around a specific class:

Flag What it does Use when
-vvv Max-verbose trace Always, to name the class
-i <key> Use this identity file Point at the exact .pem (P2)
-p <port> Connect to a non-default port sshd moved off 22 (R2)
-o ConnectTimeout=10 Fail fast instead of hanging Confirming a timeout quickly
-o IdentitiesOnly=yes Offer only the -i key Too many authentication failures (P12)
-o StrictHostKeyChecking=accept-new Trust a new host key once First connect / re-launched box
-o PubkeyAcceptedAlgorithms=+ssh-rsa Re-enable SHA-1 RSA Old RSA key on OpenSSH 8.8+ (P13)
-J user@bastion ProxyJump through a bastion Private-subnet target
-o ProxyCommand="aws ssm start-session …" Tunnel over SSM No inbound 22 / private subnet
-4 / -6 Force IPv4 / IPv6 Isolate an IP-family issue (T14)

A Host key verification failed is not an auth failure — it is your known_hosts cache disagreeing with the host key, common after a re-launch reuses an IP:

Symptom Cause Fix
Host key verification failed New host key vs cached one (re-launch reused the IP) ssh-keygen -R <host> then reconnect
REMOTE HOST IDENTIFICATION HAS CHANGED! Same as above (or a genuine MITM) Verify it is your new instance, then -R
Prompted to trust the host every time ~/.ssh/known_hosts not writable Fix its permissions / ownership
Want no prompt in automation -o StrictHostKeyChecking=accept-new (never no)

The escape hatches — when SSH is dead

When you cannot fix SSH over SSH (locked-out key, sshd down, no port 22), AWS gives you four out-of-band ways onto the box. Pre-wiring the last one (SSM) before you need it is the single highest-leverage reliability move in this article.

Method Needs port 22? Needs public IP? Prereqs Best for
EC2 Serial Console No No Account opt-in; a set OS password; Nitro instance; IAM SendSerialConsoleSSHPublicKey Boot failures, sshd down, OS-firewall lockout, read dmesg
EC2 Instance Connect (browser/CLI) Yes (22 from EIC range) Usually yes (public subnet) ec2-instance-connect pkg; IAM SendSSHPublicKey; supported AMIs Quick key-less login when the network still works
EC2 Instance Connect Endpoint No (uses the endpoint) No An EIC Endpoint in the VPC; IAM permissions Private-subnet SSH without a bastion
SSM Session Manager No No SSM Agent + instance profile AmazonSSMManagedInstanceCore + egress 443 (or VPC endpoints) The default for everything; no inbound at all
Lost-key recovery n/a n/a user-data re-inject / detach-root-volume / AWSSupport-ResetAccess You no longer have any working key

SSM Session Manager — the one to pre-install

Session Manager reaches the OS through the SSM Agent making an outbound HTTPS (443) connection to the Systems Manager service. There is no inbound port 22, no public IP, no key — which is exactly why it keeps working when SSH is dead. The price of admission is three things, wired at launch:

# 1) Instance profile with the managed policy
aws iam create-role --role-name ssm-ec2 \
  --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
aws iam attach-role-policy --role-name ssm-ec2 \
  --policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
aws iam create-instance-profile --instance-profile-name ssm-ec2
aws iam add-role-to-instance-profile --instance-profile-name ssm-ec2 --role-name ssm-ec2

# 2) Attach it to a running instance (or set at launch)
aws ec2 associate-iam-instance-profile \
  --instance-id i-0abc123 --iam-instance-profile Name=ssm-ec2

# 3) Confirm the agent registered, then open a shell — no SSH needed
aws ssm describe-instance-information \
  --query "InstanceInformationList[?InstanceId=='i-0abc123'].PingStatus"
aws ssm start-session --target i-0abc123
SSM prerequisite Why Confirm
SSM Agent installed It initiates the connection Pre-installed on AL2/AL2023, Ubuntu, RHEL; systemctl status amazon-ssm-agent
Instance profile with AmazonSSMManagedInstanceCore Agent needs permission to register describe-instances … IamInstanceProfile
Outbound 443 to SSM endpoints Agent → ssm, ssmmessages, ec2messages NAT/IGW egress, or 3 interface VPC endpoints for private subnets
AWS-StartSSHSession (optional) To tunnel real ssh over SSM ssh -o ProxyCommand="aws ssm start-session --target %h --document-name AWS-StartSSHSession --parameters portNumber=%p"

In a private subnet with no NAT, the agent reaches the service through three interface VPC endpoints — miss one and Session Manager silently never registers:

Endpoint service Purpose Required for Session Manager?
com.amazonaws.<region>.ssm Core SSM API Yes (private subnet, no NAT)
com.amazonaws.<region>.ssmmessages Session data channel Yes
com.amazonaws.<region>.ec2messages Agent ↔ service messages Yes
com.amazonaws.<region>.s3 (gateway) Session logs / patch content Only if logging to S3

EC2 Instance Connect and the Serial Console

EC2 Instance Connect pushes a temporary public key (valid ~60 seconds) into the instance’s authorized_keys via the agent, so you can log in without holding a long-lived key — but it still rides SSH, so the network must work (port 22 open, from your IP or the EC2_INSTANCE_CONNECT service prefix list). Use it for “I lost my key but the box is otherwise fine”:

aws ec2-instance-connect send-ssh-public-key \
  --instance-id i-0abc123 --instance-os-user ec2-user \
  --ssh-public-key file://~/.ssh/temp_key.pub
ssh -i ~/.ssh/temp_key ec2-user@<public-ip>   # within ~60 s

EC2 Instance Connect has a handful of prerequisites; when a browser “Connect” button greys out or the CLI push fails, it is almost always one of these:

Requirement Detail Confirm
ec2-instance-connect package Pre-installed on AL2/AL2023, Ubuntu 16.04+ rpm -q ec2-instance-connect / dpkg -l
IAM permission ec2-instance-connect:SendSSHPublicKey The caller’s policy
Network reachability Port 22 open from your IP or the EIC prefix list SG inbound rule
Supported AMI Amazon Linux, Ubuntu (not every custom AMI) Vendor docs
Correct OS user --instance-os-user ec2-user/ubuntu Matches the AMI

Serial Console is the true out-of-band path — it attaches to the instance’s serial port (ttyS0) like a crash cart, works even when the network stack or sshd is dead, and needs no port 22. Its two gates are an account-level opt-in and a password set on the OS user (the serial console needs password auth for the interactive login):

aws ec2 enable-serial-console-access          # account-wide opt-in (once)
aws ec2 get-serial-console-access-status
# then: Console → EC2 → instance → Connect → EC2 Serial Console

Recovering a lost key (P3)

If no key works and you cannot use EIC, re-inject a key you control. Three documented methods, in order of how disruptive they are:

Method Mechanism Downtime Gotcha
user-data re-inject Add cloud-config that appends your pubkey to authorized_keys, then start Stop/start user-data runs once by default — must force re-run (below)
AWSSupport-ResetAccess SSM automation resets/creates a key; needs SSM working Reboot Requires the SSM Agent + profile already present
Detach root volume Stop, detach root EBS, attach to a rescue instance, edit authorized_keys, reattach Full stop Manual, but works when everything else fails

Because user-data normally runs only on the first boot, force it to run again by prepending the cloud-config that sets scripts-user to always:

#cloud-config
cloud_final_modules:
- [scripts-user, always]

--//
Content-Type: text/x-shellscript
#!/bin/bash
echo "ssh-ed25519 AAAA...newpubkey you@laptop" >> /home/ec2-user/.ssh/authorized_keys
chown ec2-user:ec2-user /home/ec2-user/.ssh/authorized_keys
chmod 600 /home/ec2-user/.ssh/authorized_keys

Set that as the stopped instance’s user-data (aws ec2 modify-instance-attribute --instance-id i-x --user-data …), start it, and your key is appended on boot. Prefer AWSSupport-ResetAccess if SSM is already wired — it is the least error-prone.

Each escape hatch is gated by a specific IAM action — grant these deliberately (they all ultimately grant a shell) and log them in CloudTrail:

Method Key IAM action What it grants
EC2 Instance Connect ec2-instance-connect:SendSSHPublicKey Push a 60-second key to an OS user
EIC Endpoint ec2-instance-connect:OpenTunnel Tunnel to a private instance
Serial Console (login) ec2-instance-connect:SendSerialConsoleSSHPublicKey Out-of-band serial login
Serial Console (opt-in) ec2:EnableSerialConsoleAccess Account-level enablement
SSM Session Manager ssm:StartSession (+ instance profile) Shell via the agent, no port 22
Key recovery ssm:StartAutomationExecution on AWSSupport-ResetAccess Reset / create a key

Architecture at a glance

The diagram traces one SSH connection left to right and pins each failure class onto the exact hop where it bites. Read it as the map you keep open during an incident: your client and private key on the left; the internet path through the IGW and route table into the public subnet; the two firewalls (stateless NACL, stateful security group); the EC2 ENI and the sshd daemon; and finally the authentication step where your key meets authorized_keys for the right OS user. The six numbered badges are the six ways it breaks — badges 1–3 are the timeout family (no path, SG block, NACL/OS-firewall drop), badge 4 is the refused family (port closed), and badges 5–6 are the denied family (wrong key/perms, wrong username) — and the legend narrates each as symptom · confirm · fix.

SSH failure map from client to sshd: an SSH client with a private key connects over the internet through an internet gateway and route table into a public subnet, crosses a stateless NACL and a stateful security group to the EC2 ENI and sshd on port 22, then authenticates against authorized_keys for the correct OS user; six numbered badges mark where Connection timed out (no path, security-group block, NACL or OS-firewall drop), Connection refused (sshd down or wrong port), and Permission denied (wrong key or permissions, wrong username) each occur, with a legend giving the symptom, confirm command and fix for each.

Real-world scenario

Northwind Freight runs a logistics API on a fleet of t3.medium instances behind an ALB, plus a single t3.small “ops box” the on-call team SSHes into to run migration scripts. For eight months nobody thought about SSH — it just worked. Then three incidents in one quarter taught the team the whole taxonomy.

Incident 1 — the Monday timeout. Every Monday at 09:00, on-call reported “can’t SSH to the ops box, connection times out.” It always cleared by lunch. The security group allowed 22 from a single /32 — the office’s static IP. But the on-call engineer worked from home on Mondays, on a residential connection whose IP rotated over the weekend. Classic T2: a stale source. curl https://checkip.amazonaws.com from the laptop showed 49.207.x.x; the SG rule said 203.0.113.5/32 (the office). The “fix” people had been using — wait until they drove to the office — was accidental. The real fix was to stop using SSH from arbitrary IPs at all: they attached an instance profile with AmazonSSMManagedInstanceCore, and on-call switched to aws ssm start-session. The inbound 22 rule was deleted entirely. Zero timeouts since, because there is no inbound path to get wrong.

Incident 2 — the refused after patching. A nightly patch job on the ops box upgraded OpenSSH and, on one reboot, sshd failed to come back because an old sshd_config line (Protocol 2 — long removed) now made sshd -t fail. SSH gave Connection refused, and because the SG was already deleted (Incident 1’s fix), the team briefly panicked that SSM had “broken networking.” But describe-instance-status showed 2/2 — the OS was healthy, only the daemon was down. They opened Serial Console, ran sshd -t, saw Unsupported option Protocol, deleted the line, systemctl restart sshd, done. Ten minutes, no instance replacement. The lesson they wrote in the runbook: refused + 2/2 checks = the daemon, not the network; go to Serial Console, not to the VPC.

Incident 3 — the denied on the new AMI. The team baked a new golden image on Ubuntu (the old one was Amazon Linux). The first person to SSH in used muscle memory — ssh -i ops.pem ec2-user@… — and got Permission denied (publickey). They spent forty minutes re-checking the key, the SG, and authorized_keys before someone noticed the AMI change. P1: Ubuntu’s user is ubuntu, not ec2-user. ssh -i ops.pem ubuntu@… connected instantly. They added a one-liner to the launch template’s tags — ssh-user: ubuntu — so the next person would not repeat it, and standardised on ssh -vvv in the runbook so “offered key, server declined” would be read as wrong user or key, not broken network, next time.

The through-line: each incident was a different class, and every wasted minute came from acting before naming the class. After they adopted “read the error string, then the status checks, then ssh -vvv” as a fixed order — and pre-wired SSM so the worst case always had a way in — mean-time-to-recovery for access problems dropped from ~40 minutes to under 5.

Advantages and disadvantages

The strategic choice hiding inside every “can’t SSH” incident is whether you should be exposing SSH at all. Direct SSH is simple and universal; SSM Session Manager removes the entire failure surface this article is about — at the cost of a dependency on the agent and IAM.

Direct SSH (port 22, key) SSM Session Manager (no port 22)
Inbound attack surface Port 22 open to some CIDR None — no inbound rule at all
Works with no public IP Only via bastion/EIC endpoint Yes, natively
Auth Long-lived private key IAM identity + short-lived session
Audit sshd logs only Full CloudTrail + session logging to S3/CW
Failure modes All of this article Agent down / IAM / egress 443
Setup cost A key + one SG rule Instance profile + agent + endpoints
Break-glass when it’s down Serial Console Serial Console (same)
Best for Ad-hoc, learning, non-AWS targets Production, private subnets, compliance

When direct SSH matters: quick labs, non-EC2 targets, environments where you cannot install an agent, and the break-glass Serial Console path (which is SSH-like but out-of-band). When SSM wins: any production or regulated workload — deleting the inbound 22 rule eliminates T1–T14 as a class, and session logging answers “who ran what” for free. Most mature shops run SSM as the default and keep Serial Console as break-glass, reserving direct SSH for narrow cases behind a bastion or EIC endpoint.

Hands-on lab

Free-tier friendly (t2.micro/t3.micro, ~15 minutes). You will (1) launch a reachable instance, (2) reproduce a timeout by removing the SG:22 rule and confirm it is the SG via Reachability Analyzer and ssh -vvv, (3) fix it, (4) reproduce a permission-denied with the wrong username and fix it, then (5) tear everything down. ⚠️ Reachability Analyzer analyses and Elastic IPs on stopped instances incur small charges — the teardown removes them.

Step 0 — variables

export AWS_REGION=ap-south-1
export AZ=ap-south-1a
KEY=ssh-lab-key

Step 1 — a key pair, VPC path, and instance (CLI)

# Key pair (private key saved locally, locked down)
aws ec2 create-key-pair --key-name $KEY \
  --query KeyMaterial --output text > ${KEY}.pem
chmod 400 ${KEY}.pem

# Use the default VPC + a public subnet in the AZ
VPC=$(aws ec2 describe-vpcs --filters Name=isDefault,Values=true \
  --query "Vpcs[0].VpcId" --output text)
SUBNET=$(aws ec2 describe-subnets \
  --filters Name=vpc-id,Values=$VPC Name=availability-zone,Values=$AZ \
  --query "Subnets[0].SubnetId" --output text)

# Security group allowing 22 from *your* current IP only
MYIP=$(curl -s https://checkip.amazonaws.com)
SG=$(aws ec2 create-security-group --group-name ssh-lab-sg \
  --description "SSH lab" --vpc-id $VPC --query GroupId --output text)
aws ec2 authorize-security-group-ingress --group-id $SG \
  --protocol tcp --port 22 --cidr ${MYIP}/32

# Latest Amazon Linux 2023 AMI, launched into the public subnet with a public IP
AMI=$(aws ssm get-parameters \
  --names /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64 \
  --query "Parameters[0].Value" --output text)
IID=$(aws ec2 run-instances --image-id $AMI --instance-type t3.micro \
  --key-name $KEY --security-group-ids $SG --subnet-id $SUBNET \
  --associate-public-ip-address \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=ssh-lab}]' \
  --query "Instances[0].InstanceId" --output text)
aws ec2 wait instance-running --instance-ids $IID
PUB=$(aws ec2 describe-instances --instance-ids $IID \
  --query "Reservations[0].Instances[0].PublicIpAddress" --output text)
echo "Instance $IID at $PUB"

Verify the happy path (wait ~40 s for boot). Amazon Linux 2023’s user is ec2-user:

ssh -o StrictHostKeyChecking=accept-new -i ${KEY}.pem ec2-user@$PUB 'hostname; echo OK'
# Expected: the instance hostname, then OK

Step 1 (Terraform equivalent)

provider "aws" { region = "ap-south-1" }

data "aws_vpc" "default" { default = true }
data "aws_subnets" "default" {
  filter { name = "vpc-id"  values = [data.aws_vpc.default.id] }
  filter { name = "availability-zone" values = ["ap-south-1a"] }
}
data "aws_ssm_parameter" "al2023" {
  name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
}
# NOTE: pin to your own /32; http data source shown for lab convenience only
data "http" "myip" { url = "https://checkip.amazonaws.com" }

resource "aws_key_pair" "lab" {
  key_name   = "ssh-lab-key"
  public_key = file("~/.ssh/ssh-lab-key.pub") # ssh-keygen -y -f ssh-lab-key.pem > ...pub
}

resource "aws_security_group" "ssh" {
  name   = "ssh-lab-sg"
  vpc_id = data.aws_vpc.default.id
  ingress {
    description = "SSH from me"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["${chomp(data.http.myip.response_body)}/32"]
  }
  egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] }
}

resource "aws_instance" "lab" {
  ami                         = data.aws_ssm_parameter.al2023.value
  instance_type               = "t3.micro"
  subnet_id                   = data.aws_subnets.default.ids[0]
  vpc_security_group_ids      = [aws_security_group.ssh.id]
  key_name                    = aws_key_pair.lab.key_name
  associate_public_ip_address = true
  tags = { Name = "ssh-lab" }
}

output "public_ip" { value = aws_instance.lab.public_ip }

Step 2 — reproduce the timeout (remove the SG:22 rule)

aws ec2 revoke-security-group-ingress --group-id $SG \
  --protocol tcp --port 22 --cidr ${MYIP}/32

ssh -vvv -o ConnectTimeout=10 -i ${KEY}.pem ec2-user@$PUB 'echo hi'
# Expected -vvv tail:
#   debug1: Connecting to <PUB> [<PUB>] port 22.
#   (long pause)
#   ssh: connect to host <PUB> port 22: Connection timed out

The hang-then-timeout, not a fast refusal, tells you a firewall silently dropped the SYN — the network class, not the daemon.

Step 3 — confirm it is the SG with Reachability Analyzer

IGW=$(aws ec2 describe-internet-gateways \
  --filters Name=attachment.vpc-id,Values=$VPC \
  --query "InternetGateways[0].InternetGatewayId" --output text)
PID=$(aws ec2 create-network-insights-path --source $IGW --destination $IID \
  --protocol tcp --destination-port 22 \
  --query NetworkInsightsPath.NetworkInsightsPathId --output text)
AID=$(aws ec2 start-network-insights-analysis --network-insights-path-id $PID \
  --query NetworkInsightsAnalysis.NetworkInsightsAnalysisId --output text)
sleep 8
aws ec2 describe-network-insights-analyses --network-insights-analysis-ids $AID \
  --query "NetworkInsightsAnalyses[].{Reachable:NetworkPathFound,Code:Explanations[0].ExplanationCode}"
# Expected: Reachable=false, Code contains an SG mismatch explanation

Step 4 — fix the timeout, then break auth instead

# Fix: re-add the rule for your current IP
MYIP=$(curl -s https://checkip.amazonaws.com)
aws ec2 authorize-security-group-ingress --group-id $SG \
  --protocol tcp --port 22 --cidr ${MYIP}/32
ssh -o StrictHostKeyChecking=accept-new -i ${KEY}.pem ec2-user@$PUB 'echo network-fixed'
# Expected: network-fixed

# Now reproduce Permission denied — wrong username (this AMI is ec2-user)
ssh -vvv -i ${KEY}.pem ubuntu@$PUB 'echo hi'
# Expected -vvv tail:
#   debug1: Authentications that can continue: publickey
#   debug1: Offering public key: ssh-lab-key.pem ...
#   debug1: Authentications that can continue: publickey
#   ubuntu@<PUB>: Permission denied (publickey).

# Fix: correct username
ssh -i ${KEY}.pem ec2-user@$PUB 'echo auth-fixed'
# Expected: auth-fixed

You have now produced and fixed both headline failures and seen how -vvv distinguishes them: a silent hang (network) versus Offering public key → decline (auth).

Step 5 — teardown (⚠️ stops charges)

aws ec2 terminate-instances --instance-ids $IID
aws ec2 wait instance-terminated --instance-ids $IID
aws ec2 delete-security-group --group-id $SG
aws ec2 delete-key-pair --key-name $KEY
aws ec2 delete-network-insights-analysis --network-insights-analysis-id $AID
aws ec2 delete-network-insights-path --network-insights-path-id $PID
rm -f ${KEY}.pem
# Terraform: terraform destroy

Common mistakes & troubleshooting

This is the reference you keep open at 09:40. First the master playbook — 20 rows spanning all three classes, each with the exact confirm command and fix. Then the decode and the ordered runbook.

# Symptom (exact error) Category Root cause Confirm Fix
1 Connection timed out Timeout No inbound SG rule for 22 describe-security-groups … IpPermissions[?FromPort==\22`]` authorize-security-group-ingress --port 22 --cidr <you>/32
2 Connection timed out Timeout Stale My IP / dynamic ISP curl checkip.amazonaws.com vs the rule CIDR Update the rule to your current /32
3 Connection timed out Timeout No public IP / EIP describe-instances … PublicIpAddress is null Associate an EIP; enable auto-assign
4 Connection timed out Timeout No route 0.0.0.0/0 → igw describe-route-tables for the subnet create-route … --gateway-id igw-x
5 Connection timed out Timeout Private subnet (no internet path) Route table has no IGW route Use SSM Session Manager / bastion / EIC endpoint
6 Connection timed out Timeout Custom NACL missing out-ephemeral describe-network-acls; Flow Logs REJECT Add outbound tcp 1024-65535 to your CIDR
7 Connection timed out Timeout OS firewall -j DROP (ufw/iptables) Serial Console: ufw status / iptables -L -n Allow 22 in-guest, or disable for triage
8 Connection timed out Timeout Corporate egress blocks 22 Works from phone hotspot, not office Use SSM over 443, or a firewall exception
9 Operation timed out (macOS) Timeout Same as any timeout above ssh -vvv hang after Connecting to … Work the timeout table T1–T14
10 Connection refused Refused sshd not running / crashed Serial Console: systemctl status sshd systemctl start sshd && systemctl enable sshd
11 Connection refused Refused sshd on a non-standard port Serial Console: ss -tlnp | grep ssh ssh -p <port> and open that port in the SG
12 Connection refused Refused Instance still booting Status checks not yet 2/2 Wait; watch describe-instance-status
13 Connection refused Refused fail2ban banned your IP Serial Console: fail2ban-client status sshd fail2ban-client set sshd unbanip <you>
14 kex_exchange_identification: Connection closed Refused-ish fail2ban / MaxStartups / sshd restart Serial Console: journalctl -u sshd Unban; raise MaxStartups; retry
15 Permission denied (publickey) Denied Wrong AMI username ssh -vvv offers key → server declines Use ec2-user/ubuntu/admin per AMI
16 Permission denied (publickey) Denied Wrong private key -vvv: Offering public key → decline ssh -i <correct>.pem; check launch KeyName
17 Permission denied (publickey) Denied Key not in authorized_keys Instance KeyName empty / key removed Re-inject via user-data / EIC / AWSSupport-ResetAccess
18 UNPROTECTED PRIVATE KEY FILE! Denied Local key perms too open ls -l key.pem shows group/other bits chmod 400 key.pem
19 Permission denied with right key Denied Server ~/.ssh perms (StrictModes) Serial Console: ls -ld ~/.ssh chmod 700 ~/.ssh; chmod 600 authorized_keys
20 no mutual signature algorithm Denied ssh-rsa/SHA-1 off (OpenSSH 8.8+) -vvv: send_pubkey_test: no mutual signature -o PubkeyAcceptedAlgorithms=+ssh-rsa, or new key
21 Too many authentication failures Denied Agent offers too many keys -vvv shows several Offering ssh -o IdentitiesOnly=yes -i key.pem
22 Host key verification failed Not-auth Reused IP → host key changed WARNING: REMOTE HOST IDENTIFICATION CHANGED ssh-keygen -R <host> then reconnect

The 30-second decode

If you see… It’s almost certainly… Do this first
A slow hang then timed out A firewall/path drop (network) Check the SG rule + your current IP
A fast refused The port is closed on a reachable host Serial Console → systemctl status sshd
Permission denied (publickey) fast Wrong user or wrong key Fix user@, then -i key, then perms
timed out but SG looks perfect NACL out-ephemeral, no route, or no public IP Reachability Analyzer
refused but you just launched Still booting Wait for 2/2 status checks
denied but the key is definitely right Username, StrictModes, or ssh-rsa ssh -vvv; read Server accepts key vs decline

Checks in order (the runbook)

Do not skip ahead — each step rules out a layer cheaply before the next:

Order Check Command If it fails →
1 Read the exact error class ssh -vvv -i key user@host Pick the column: timeout / refused / denied
2 Instance running + public IP describe-instances … State, PublicIpAddress Start it / attach EIP (T4, T7)
3 Your current IP vs the SG rule curl checkip.amazonaws.com; describe-security-groups Update the /32 (T1, T2)
4 Route to IGW + NACL describe-route-tables; describe-network-acls Add route / NACL rules (T5, T9, T10)
5 Deterministic path check Reachability Analyzer to port 22 Fix the named component
6 Status checks (is the OS alive?) describe-instance-status 1/2 → Serial Console, boot logs
7 Daemon up + right port Serial Console: systemctl status sshd; ss -tlnp Start/enable sshd; use real port (R1, R2)
8 Username for the AMI try ec2-user / ubuntu / admin Use the right user (P1)
9 Key correct + local perms chmod 400 key.pem; ssh -i <right> Right key + 0400 (P2, P4)
10 Server-side perms / StrictModes Serial Console: ls -ld ~/.ssh; sshd -T 700/600; fix config (P5, P9, P13)

Best practices

Security notes

The SSH front door is the most attacked port on the internet, so every fix here has a security dimension. Least privilege on the network: scope inbound 22 to the narrowest source you can — a /32, a bastion SG reference, or the EC2_INSTANCE_CONNECT prefix list — never 0.0.0.0/0. The moment you can, delete the inbound rule and move to SSM Session Manager, which needs no inbound access and authenticates with IAM, so access is governed by policies, MFA and SCPs rather than possession of a .pem. Least privilege on identity: the IAM actions that back these tools — ec2-instance-connect:SendSSHPublicKey, ssm:StartSession, ec2:EnableSerialConsoleAccess — are powerful (they grant shell), so gate them behind conditions (tags, source IP) and log them in CloudTrail. Key hygiene: private keys are bearer tokens — chmod 400, never commit them (a leaked .pem in git is a breach), rotate them, and prefer short-lived EIC keys for humans. Server-side: keep PasswordAuthentication no and PermitRootLogin no, run fail2ban to throttle brute force (and know it can be your lockout — R4), and keep sshd patched (the same 8.8 change that broke your old key also closed real weaknesses). Audit trail: direct SSH logs only to the box; SSM logs who opened a session, when, and optionally every command, to S3/CloudWatch — which is what an auditor actually asks for.

Cost & sizing

SSH access itself is free — you pay for the surrounding pieces, all modest.

Item Cost driver Rough figure (2026) Free-tier / note
EC2 instance Instance-hours t3.micro ≈ ₹0.90–1.10 / hr (~$0.011) 750 hrs/mo t2/t3.micro first 12 months
Elastic IP Now billed even when attached ≈ ₹0.40 / hr (~$0.005) per EIP Every public IPv4 is chargeable since 2024
SSM Session Manager The service is free ₹0 Pay only for optional S3/CW session logs
SSM VPC endpoints Interface endpoints (private subnets) ≈ ₹0.85 / hr (~$0.01) each × 3 + data Avoids a NAT gateway; needed with no egress
Reachability Analyzer Per analysis run ≈ ₹8–9 (~$0.10) per analysis Delete the path after use
Serial Console Free ₹0 Only the instance-hours apply
NAT gateway (if agents egress via NAT) Hourly + per-GB ≈ ₹3.7 / hr (~$0.045) + data Prefer VPC endpoints for SSM-only egress

Right-sizing guidance: for a pure ops/bastion box, t3.micro or t4g.micro (Graviton, cheaper) is plenty. If you go all-in on SSM in private subnets, the three interface endpoints (ssm, ssmmessages, ec2messages) cost more than a single NAT gateway only at very low scale — above a few instances the endpoints usually win and are more secure. Remember the 2024 change: every public IPv4 address is billed hourly, so an idle stopped instance still holding an EIP quietly costs money — the lab teardown removes it.

Interview & exam questions

Q1. A user reports Connection timed out on SSH. What are the first three things you check, and why in that order? Current source IP vs the SG rule (most common, stale My IP), then that the instance is running with a public IP, then the route to the IGW. You order them by probability and cost-to-check. A timeout is a network failure, so you never look at sshd or the key yet. (SAA-C03, SOA-C02)

Q2. Connection refused vs Connection timed out — what does each tell you about the security group? A refusal proves the security group (and NACL, route, public IP) is correct — the packet reached a host that answered with RST, so the problem is the daemon/port inside. A timeout means the packet was dropped before reaching a listening host, so the SG/NACL/route/path is the prime suspect. (SOA-C02)

Q3. Why does Permission denied (publickey) happen with the correct key, and how do you confirm the cause? The username is wrong for the AMI (e.g., ec2-user on Ubuntu, which wants ubuntu), the key isn’t in that user’s authorized_keys, or server-side permissions fail StrictModes. ssh -vvv: Offering public key → decline points to wrong key/user; Server accepts key then denied points to permissions. (DVA-C02, SAA-C03)

Q4. You must SSH to an instance in a private subnet with no public IP. Options? SSM Session Manager (outbound 443, no inbound, no public IP — the preferred answer), an EC2 Instance Connect Endpoint, or a bastion host in a public subnet. SSM also gives IAM auth and full session audit. (SAA-C03, SCS-C02)

Q5. sshd is down and you cannot SSH in. How do you get a shell to fix it? EC2 Serial Console (out-of-band, needs account opt-in + an OS password, works even with the network stack down) or SSM Session Manager if the agent is still running. Both bypass port 22. (SOA-C02)

Q6. A custom NACL allows inbound 22 but SSH still times out. What’s the classic cause? NACLs are stateless, so the reply must be explicitly allowed outbound on the ephemeral range (1024–65535) back to the client. Without it, the SYN-ACK is dropped and the client times out despite the inbound rule. (ANS-C01, SOA-C02)

Q7. What changed in OpenSSH 8.8 that breaks old keys, and how do you fix it? The ssh-rsa (RSA-with-SHA-1) signature algorithm was disabled by default. A valid RSA key can then get Permission denied / no mutual signature algorithm. Fix: -o PubkeyAcceptedAlgorithms=+ssh-rsa as a stopgap, or regenerate an ed25519 key. (DVA-C02)

Q8. How do you recover an instance whose only key is lost? AWSSupport-ResetAccess (SSM automation, if the agent/profile exist), re-inject a key via user-data forced to re-run (cloud_final_modules: [scripts-user, always]), or stop the instance, detach the root EBS volume, attach it to a rescue instance, edit authorized_keys, and reattach. (SOA-C02)

Q9. Your key works but you get WARNING: UNPROTECTED PRIVATE KEY FILE! and then denied. Why? The client refuses to use a private key with group/world-readable permissions, so no key is offered and auth fails. chmod 400 key.pem. (DVA-C02)

Q10. How do you get a single, deterministic answer to “what is blocking port 22”? VPC Reachability Analyzer: create a network-insights path from the IGW (or your ENI) to the instance on TCP 22, run the analysis, and read NetworkPathFound plus the ExplanationCode, which names the SG, NACL or route that blocks it. (ANS-C01, SOA-C02)

Q11. Too many authentication failures before you even type anything — why? Your ssh-agent is offering multiple keys and sshd cuts you off after MaxAuthTries. Force a single identity: ssh -o IdentitiesOnly=yes -i thecorrect.pem. (DVA-C02)

Q12. After re-launching an instance that reused an IP, SSH fails with Host key verification failed. Is this an AWS problem? No — the new instance has a different host key than the one cached in your known_hosts for that IP, which (correctly) trips a MITM warning. ssh-keygen -R <host> and reconnect. (SOA-C02)

Quick check

  1. Your terminal prints Connection refused. Is the security group a likely cause? Why or why not?
  2. Which AMI uses ubuntu, which uses ec2-user, and which uses admin as the SSH user?
  3. A custom NACL allows inbound 22 but SSH times out. What single rule is probably missing?
  4. Name the one access method that needs neither port 22 nor a public IP, and its two prerequisites.
  5. In ssh -vvv, what distinguishes “wrong key/user” from “right key, bad server-side permissions”?

Answers

  1. No. A refusal means the packet reached a host that answered with RST — so the SG (and NACL/route/public IP) let it through. The problem is the closed port / dead daemon inside, not the firewall.
  2. Ubuntu → ubuntu; Amazon Linux/RHEL → ec2-user; Debian → admin. Using the wrong one yields Permission denied (publickey) with a correct key.
  3. The outbound ephemeral-range rule (tcp 1024–65535 to the client’s CIDR). NACLs are stateless, so the reply must be explicitly allowed or the client times out.
  4. SSM Session Manager. Prerequisites: the SSM Agent running, and an instance profile with AmazonSSMManagedInstanceCore (plus outbound 443 to the SSM endpoints).
  5. Offering public key → server declines = wrong key or wrong user (auth attempted, rejected). Server accepts key then denied = the key is right, so it is server-side permissions / StrictModes.

Glossary

Next steps

AWSEC2SSHSecurity GroupsSSM Session ManagerEC2 Instance ConnectReachability AnalyzerTroubleshooting
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading