Photo by U. Storsberg on Unsplash
If you can’t SSH into an EC2 instance, check in this order: the instance is running and passes both status checks, it has a public IP, its subnet routes 0.0.0.0/0 to an internet gateway, a security group allows TCP 22 from your IP, the network ACL allows port 22 in and ephemeral ports out, and you’re using the right key and user name.
An SSH failure usually shows up as one of three messages, and each one points at a different layer. Connection timed out means packets never arrive: state, routing, security group or network ACL. Connection refused means the packets arrive but nothing listens on port 22. Permission denied (publickey) means the network is fine and the key or user name is wrong.
This example gives you a read-only TypeScript script for the AWS SDK for JavaScript v3 that checks every AWS-side setting in one run and prints PASS or FAIL for each. It’s part of the collection of AWS SDK v3 examples for everyday operations.
What does the script check?
- State and status checks
DescribeInstancesgives the state, andDescribeInstanceStatuswithIncludeAllInstancesgives the system and instance status checks. A failed instance check often means the OS didn’t boot, a full disk, or a broken/etc/fstab. - Public IPv4 addressWithout one, the instance is reachable only from inside the VPC, over a VPN, or through an EC2 Instance Connect Endpoint.
- Route to an internet gatewayIt reads the subnet’s route table with
DescribeRouteTables, falling back to the VPC’s main table when the subnet has no explicit association, and looks for an activeigw-route that covers your IP. - Security groupIt checks every group on the instance for an inbound TCP rule that includes port 22 and a CIDR range containing your public IP.
- Network ACLACL rules are evaluated in rule-number order and the first match wins. The script evaluates inbound port 22 and outbound traffic back to your ephemeral ports, because ACLs are stateless.
- Key pair and user nameIt prints the key pair name and guesses the default user from the AMI name, the mistake behind most
Permission denied (publickey)errors.
Prerequisites
- Node.js 18 or later (the script uses the built-in
fetchto look up your public IP), npm andtsx. - The
@aws-sdk/client-ec2package. - An AWS profile with the describe permissions below, in the instance’s region.
Which IAM permissions does it need?
Only EC2 describe actions, which don’t support resource-level permissions. Nothing in this policy can change the instance or its network.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadSshReachability",
"Effect": "Allow",
"Action": [
"ec2:DescribeInstances",
"ec2:DescribeInstanceStatus",
"ec2:DescribeSecurityGroups",
"ec2:DescribeNetworkAcls",
"ec2:DescribeRouteTables",
"ec2:DescribeImages"
],
"Resource": "*"
}
]
}
If you extend the script, the IAM policy generator for TypeScript AWS code drafts the updated action list for you.
The script: why can’t I SSH into my EC2 instance?
// check-ssh-reachability.ts
// Checks the AWS-side configuration that decides whether you can SSH into an EC2 instance:
// state, status checks, public IP, route to an internet gateway, security group, network ACL,
// key pair and the likely user name. Read-only: it never changes anything.
// Usage: npx tsx check-ssh-reachability.ts i-0123456789abcdef0 [--my-ip 203.0.113.10] [--port 22]
import {
EC2Client,
DescribeInstancesCommand,
DescribeInstanceStatusCommand,
DescribeSecurityGroupsCommand,
DescribeNetworkAclsCommand,
DescribeRouteTablesCommand,
DescribeImagesCommand,
type NetworkAclEntry,
type RouteTable,
} from "@aws-sdk/client-ec2";
const args = process.argv.slice(2);
const instanceId = args.find((a) => a.startsWith("i-"));
if (!instanceId) throw new Error("Pass an instance ID, e.g. i-0123456789abcdef0");
const argValue = (name: string): string | undefined => {
const i = args.indexOf(name);
return i !== -1 ? args[i + 1] : undefined;
};
const PORT = Number(argValue("--port") ?? 22);
const region = process.env.AWS_REGION ?? "us-east-1";
const ec2 = new EC2Client({ region });
// --- IPv4 helpers
const ipToInt = (ip: string): number =>
ip.split(".").reduce((acc, octet) => ((acc << 8) + Number(octet)) >>> 0, 0);
function inCidr(ip: string, cidr: string): boolean {
const [base, bits] = cidr.split("/");
const prefix = Number(bits);
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
return ((ipToInt(ip) & mask) >>> 0) === ((ipToInt(base) & mask) >>> 0);
}
const results: { check: string; ok: boolean; detail: string }[] = [];
const report = (check: string, ok: boolean, detail: string) => results.push({ check, ok, detail });
async function myPublicIp(): Promise<string> {
const given = argValue("--my-ip");
if (given) return given;
const res = await fetch("https://checkip.amazonaws.com");
return (await res.text()).trim();
}
// Network ACLs are evaluated in rule-number order; the first matching rule wins.
function aclDecision(entries: NetworkAclEntry[], egress: boolean, ip: string, port: number): string {
const rules = entries
.filter((e) => e.Egress === egress && e.CidrBlock)
.sort((a, b) => (a.RuleNumber ?? 0) - (b.RuleNumber ?? 0));
for (const r of rules) {
const protoMatch = r.Protocol === "-1" || r.Protocol === "6";
const portMatch = r.Protocol === "-1" || (port >= (r.PortRange?.From ?? 0) && port <= (r.PortRange?.To ?? 65535));
if (protoMatch && portMatch && inCidr(ip, r.CidrBlock!)) return `${r.RuleAction} (rule ${r.RuleNumber})`;
}
return "deny (no rule matched)";
}
function guessUser(amiName: string, platform: string): string {
const n = `${amiName} ${platform}`.toLowerCase();
if (n.includes("windows")) return "Windows: use RDP, not SSH";
if (n.includes("ubuntu")) return "ubuntu";
if (n.includes("debian")) return "admin";
if (n.includes("bitnami")) return "bitnami";
if (n.includes("centos")) return "centos or ec2-user";
if (n.includes("fedora")) return "fedora or ec2-user";
if (n.includes("rocky")) return "rocky";
return "ec2-user (Amazon Linux, RHEL, SUSE, Oracle Linux)";
}
async function main(): Promise<void> {
const myIp = await myPublicIp();
console.log(`Checking ${instanceId} in ${region} for SSH on port ${PORT} from ${myIp}\n`);
const desc = await ec2.send(new DescribeInstancesCommand({ InstanceIds: [instanceId!] }));
const inst = desc.Reservations?.[0]?.Instances?.[0];
if (!inst) throw new Error(`Instance ${instanceId} not found in ${region}`);
// 1. State and status checks
const state = inst.State?.Name ?? "unknown";
report("Instance state", state === "running", state);
const status = await ec2.send(new DescribeInstanceStatusCommand({ InstanceIds: [instanceId!], IncludeAllInstances: true }));
const st = status.InstanceStatuses?.[0];
const sys = st?.SystemStatus?.Status ?? "unknown";
const os = st?.InstanceStatus?.Status ?? "unknown";
report("Status checks", sys === "ok" && os === "ok", `system ${sys}, instance ${os}`);
// 2. Public address
report("Public IPv4 address", !!inst.PublicIpAddress, inst.PublicIpAddress ?? "none (reachable only from inside the VPC, a VPN or an EC2 Instance Connect Endpoint)");
// 3. Route to an internet gateway (subnet's route table, or the VPC's main table)
const subnetId = inst.SubnetId!;
const vpcId = inst.VpcId!;
let tables: RouteTable[] = (await ec2.send(new DescribeRouteTablesCommand({
Filters: [{ Name: "association.subnet-id", Values: [subnetId] }],
}))).RouteTables ?? [];
if (tables.length === 0) {
tables = (await ec2.send(new DescribeRouteTablesCommand({
Filters: [{ Name: "vpc-id", Values: [vpcId] }, { Name: "association.main", Values: ["true"] }],
}))).RouteTables ?? [];
}
const igwRoute = tables[0]?.Routes?.find((r) => r.GatewayId?.startsWith("igw-") && r.State === "active"
&& r.DestinationCidrBlock !== undefined && inCidr(myIp, r.DestinationCidrBlock));
report("Route to internet gateway", !!igwRoute, igwRoute ? `${igwRoute.DestinationCidrBlock} -> ${igwRoute.GatewayId}` : `no active igw route for ${myIp} in ${tables[0]?.RouteTableId ?? "?"}`);
// 4. Security groups: an inbound TCP rule for the port that includes your IP
const groupIds = (inst.SecurityGroups ?? []).map((g) => g.GroupId!).filter(Boolean);
const sgs = (await ec2.send(new DescribeSecurityGroupsCommand({ GroupIds: groupIds }))).SecurityGroups ?? [];
const sgMatch = sgs.flatMap((g) => (g.IpPermissions ?? []).map((p) => ({ g, p }))).find(({ p }) => {
const proto = p.IpProtocol === "-1" || p.IpProtocol === "tcp";
const port = p.IpProtocol === "-1" || (PORT >= (p.FromPort ?? 0) && PORT <= (p.ToPort ?? 65535));
return proto && port && (p.IpRanges ?? []).some((r) => r.CidrIp && inCidr(myIp, r.CidrIp));
});
report("Security group inbound", !!sgMatch, sgMatch ? `${sgMatch.g.GroupId} allows ${PORT} from ${sgMatch.p.IpRanges?.map((r) => r.CidrIp).join(", ")}` : `no rule in ${groupIds.join(", ")} allows tcp/${PORT} from ${myIp}`);
// 5. Network ACL: inbound to the port, outbound back to your ephemeral ports
const acls = (await ec2.send(new DescribeNetworkAclsCommand({
Filters: [{ Name: "association.subnet-id", Values: [subnetId] }],
}))).NetworkAcls ?? [];
const entries = acls[0]?.Entries ?? [];
const inbound = aclDecision(entries, false, myIp, PORT);
const outbound = [1024, 32768, 49152, 65535].map((p) => aclDecision(entries, true, myIp, p));
report("Network ACL inbound", inbound.startsWith("allow"), `${acls[0]?.NetworkAclId ?? "?"}: tcp/${PORT} ${inbound}`);
report("Network ACL outbound (ephemeral)", outbound.every((d) => d.startsWith("allow")), `ports 1024-65535: ${[...new Set(outbound)].join("; ")}`);
// 6. Key pair and user name
report("Key pair", !!inst.KeyName, inst.KeyName ?? "none: use EC2 Instance Connect or Session Manager");
const image = inst.ImageId
? (await ec2.send(new DescribeImagesCommand({ ImageIds: [inst.ImageId] }))).Images?.[0]
: undefined;
const user = guessUser(image?.Name ?? "", inst.PlatformDetails ?? "");
console.log(`AMI: ${image?.Name ?? `${inst.ImageId} (no longer visible)`} | likely user: ${user}\n`);
for (const r of results) console.log(`${r.ok ? "PASS" : "FAIL"} ${r.check.padEnd(34)} ${r.detail}`);
if (results.every((r) => r.ok) && inst.PublicIpAddress && inst.KeyName && !user.startsWith("Windows")) {
console.log(`\nAWS side looks fine. Try: ssh -i ${inst.KeyName}.pem ${user.split(" ")[0]}@${inst.PublicIpAddress}`);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
The script looks up your public IP from checkip.amazonaws.com. If you connect through a corporate proxy or VPN, pass the address your traffic really leaves from with --my-ip.
How do you run it?
npm install @aws-sdk/client-ec2
npm install --save-dev tsx typescript
AWS_PROFILE=readonly AWS_REGION=us-east-1 npx tsx check-ssh-reachability.ts i-0123456789abcdef0
# Check from a specific source address, or a non-default SSH port
AWS_PROFILE=readonly AWS_REGION=us-east-1 npx tsx check-ssh-reachability.ts i-0123456789abcdef0 --my-ip 198.51.100.24 --port 2222
Sample output
Checking i-0123456789abcdef0 in us-east-1 for SSH on port 22 from 203.0.113.10
AMI: al2023-ami-2023.8.20260910.0-kernel-6.1-x86_64 | likely user: ec2-user (Amazon Linux, RHEL, SUSE, Oracle Linux)
PASS Instance state running
PASS Status checks system ok, instance ok
PASS Public IPv4 address 54.210.18.77
PASS Route to internet gateway 0.0.0.0/0 -> igw-0a1b2c3d4e5f67890
FAIL Security group inbound no rule in sg-0f1e2d3c4b5a69788 allows tcp/22 from 203.0.113.10
PASS Network ACL inbound acl-0123abcd4567ef890: tcp/22 allow (rule 100)
PASS Network ACL outbound (ephemeral) ports 1024-65535: allow (rule 100)
PASS Key pair deploy-key
IDs, addresses and names are illustrative. Here the security group allows SSH from an old office range, not from the address you’re connecting from today, which produces a timeout rather than an error.
How do you fix each failure?
- State or status checks. Start a stopped instance. A failed system check usually clears after a stop and start, which moves the instance to new hardware. A failed instance check needs the console output or a screenshot from the EC2 console to see why the OS didn’t boot.
- No public IP. A public IP assigned at launch changes after every stop and start. Attach an Elastic IP if you need a stable address, and release the ones you stop using; the example to find and release unassociated Elastic IP addresses handles that cleanup.
- No route to an internet gateway. The instance is in a private subnet. Use Session Manager or an EC2 Instance Connect Endpoint rather than making the subnet public.
- Security group. Add an inbound rule for TCP 22 from your address as a /32, not from
0.0.0.0/0. The script only reads CIDR ranges; a rule that uses a prefix list or references another security group shows as FAIL even if it would allow you, so check those by hand. The sibling example to find security groups open to the internet on common ports catches the opposite problem. - Network ACL. Add an inbound allow for TCP 22 and an outbound allow for TCP 1024-65535 to your address, with rule numbers lower than any deny that matches.
- Key or user name. Use the private key that matches the key pair, run
chmod 400on it (SSH refuses keys other users can read), and use the right user:ec2-userfor Amazon Linux,ubuntufor Ubuntu,adminfor Debian. Key pairs whose private key nobody holds any more can go; the script to find unused EC2 key pairs lists the ones no instance, launch template or launch configuration references.
If every check passes and you still see Connection refused, the problem is inside the OS: sshd stopped, it listens on another port, or a host firewall blocks it. AWS’s guide to troubleshoot connecting to your Amazon EC2 Linux instance covers those OS-level cases in detail.
Can you connect without SSH keys or port 22?
Yes, two ways. EC2 Instance Connect pushes a one-time public key to the instance for 60 seconds, so you don’t need to keep the original private key. It still uses SSH on port 22, and it needs the Instance Connect package, which recent Amazon Linux and Ubuntu AMIs include. An EC2 Instance Connect Endpoint extends that to instances in private subnets without a public IP.
Session Manager, part of AWS Systems Manager, needs no inbound port at all. The instance runs the SSM Agent, has an instance profile with the AmazonSSMManagedInstanceCore managed policy, and can reach the Systems Manager endpoints over HTTPS. You can then close port 22 entirely, which is what a review of your AWS security posture would recommend anyway. To see which instances in the fleet don’t meet those three conditions, run the script to find EC2 instances not managed by Systems Manager.
Ask ChatWithCloud instead
You can ask the same questions in plain English: “Why can’t I SSH into i-0123456789abcdef0? Check its security groups, network ACL and route table.” ChatWithCloud writes AWS SDK for JavaScript v2 code, runs it locally with your profile, and explains what it found, as the guide to troubleshoot AWS infrastructure with an AI CLI shows. Keep it to a read-only profile, since generated code runs without a confirmation step; connecting ChatWithCloud to a read-only AWS profile takes a few minutes, and how ChatWithCloud handles your AWS data explains what’s sent for processing. It can’t see inside the OS, so sshd and host-firewall problems stay out of reach.
Frequently asked questions
Why does SSH to EC2 time out instead of failing?
A timeout means your packets are silently dropped: a security group without a matching rule, a network ACL deny, no route to an internet gateway, no public IP, or a stopped instance. Security groups and ACLs don’t send rejections.
Why can’t I SSH into my EC2 instance after stopping and starting it?
Its auto-assigned public IP changed. Use the new address from DescribeInstances, or attach an Elastic IP. You may also need to remove the old host key from ~/.ssh/known_hosts.
What does Permission denied (publickey) mean on EC2?
The network works but authentication failed: the wrong private key, the wrong user name for the AMI, or a key file with permissions that are too open.
Do I need port 22 open to use Session Manager?
No. Session Manager connects outbound from the SSM Agent over HTTPS, so the security group needs no inbound rules for it.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud
