Consulting and Craft · Hands On

SSH to EC2 Instances by ID, Without Bastions or Open Ports

· 20 min read

ssh i-0abc123 should be all it takes. The instance ID is the one name every EC2 instance has, it never collides, and it is what the console, the alarm and the incident channel all quote at you. What it does not carry is the account or the Region, so the hard part is finding the instance rather than reaching it.

Two AWS features do the reaching. Session Manager opens a tunnel from your laptop to the SSM Agent on the instance, and the agent dials out over HTTPS, so nothing has to listen on the internet and no inbound security group rule is needed. EC2 Instance Connect pushes a public key to the instance for one login; the key is gone 60 seconds later, so there is no authorized_keys file to curate and nobody’s key outlives their access. SSH runs inside the Session Manager tunnel, which means scp, rsync, port forwarding and every SSH-aware tool you already use keep working.

Laptop ssh i-0abc123 runs ProxyCommand aws-ssm-proxy EC2 Instance Connect SendSSHPublicKey Session Manager AWS-StartSSHSession Instance key valid for 60 seconds sshd on localhost:22 SSM Agent no inbound rules 1 2 outbound 443
Step 1 puts a key on the instance for one login. Step 2 opens a tunnel that arrives at the instance from the inside, through a connection the agent made itself.

On the instance

Three things, and current AMIs ship two of them.

SSM Agent, version 2.3.672.0 or later for SSH sessions. AWS preinstalls it on Amazon Linux 2, AL2023 and the Ubuntu Server LTS AMIs from 18.04 onwards. It needs an IAM identity: either the account-wide Default Host Management Configuration, or an instance profile with the AmazonSSMManagedInstanceCore managed policy. It also needs outbound HTTPS to the Systems Manager endpoints, directly, through a NAT gateway, or through the ssm, ssmmessages and ec2messages interface endpoints in a VPC with no route out.

EC2 Instance Connect, which is preinstalled on the AL2023 standard AMI, Amazon Linux 2 from 2.0.20190618, and Ubuntu 20.04 onwards. It sets AuthorizedKeysCommand in sshd_config so sshd looks up keys pushed through the API as well as the ones on disk. If you already set AuthorizedKeysCommand yourself, the install leaves your setting in place and Instance Connect does not work.

sshd running, which it is. The security group can have no inbound rules at all, because the connection reaches sshd from the agent on the same machine.

Who may connect

One policy on the people (or the permission set) who connect. Session Manager needs permission to start a session on the instance with the SSH document; Instance Connect needs permission to push a key, and the ec2:osuser condition pins which local account that key can log in as:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "ssm:StartSession",
      "Resource": [
        "arn:aws:ec2:*:111122223333:instance/*",
        "arn:aws:ssm:*:*:document/AWS-StartSSHSession",
        "arn:aws:ssm:*:*:document/AWS-StartPortForwardingSessionToRemoteHost"
      ]
    },
    {
      "Effect": "Allow",
      "Action": "ssmmessages:OpenDataChannel",
      "Resource": "arn:aws:ssm:*:*:session/${aws:userid}-*"
    },
    {
      "Effect": "Allow",
      "Action": "ec2-instance-connect:SendSSHPublicKey",
      "Resource": "arn:aws:ec2:*:111122223333:instance/*",
      "Condition": {"StringEquals": {"ec2:osuser": "ec2-user"}}
    },
    {
      "Effect": "Allow",
      "Action": ["ec2:DescribeInstances", "ec2:DescribeRegions"],
      "Resource": "*"
    }
  ]
}

instance/* is every instance in the account. Narrow it to specific ARNs, or to a tag condition, before giving it to anyone who should not reach everything; without a restriction, anyone holding SendSSHPublicKey can push a key to every instance. The ec2:Describe* actions do not support resource-level permissions, so they stay on *; the script below uses them to find instances.

On the laptop

Install the AWS CLI v2 and the Session Manager plugin, version 1.1.23.0 or later. On a Mac with Apple silicon:

curl -o session-manager-plugin.pkg \
  https://s3.amazonaws.com/session-manager-downloads/plugin/latest/mac_arm64/session-manager-plugin.pkg
sudo installer -pkg session-manager-plugin.pkg -target /

(Intel Macs use mac/ in place of mac_arm64/.)

Then the proxy script. SSH calls it with the instance ID, user and port, and it does three things: works out which profile and Region the instance lives in, pushes your public key, and hands over to Session Manager. Save it as ~/.ssh/aws-ssm-proxy and chmod +x it:

#!/usr/bin/env bash
# ProxyCommand for `ssh i-...`: find the instance, push a key, open the tunnel.
set -euo pipefail
instance=$1 user=$2 port=$3
pubkey=${AWS_SSH_PUBKEY:-$HOME/.ssh/id_ed25519.pub}
cache=${XDG_CACHE_HOME:-$HOME/.cache}/aws-ssm-proxy
mkdir -p "$cache"

# Try every profile in every Region it can see, sixteen at a time, and
# print the first "profile region" pair that knows this instance ID.
find_instance() {
  for profile in $(aws configure list-profiles); do
    aws ec2 describe-regions --profile "$profile" \
        --query 'Regions[].RegionName' --output text 2>/dev/null |
      tr '\t' '\n' | sed "s/^/$profile /"
  done |
  xargs -P 16 -n 2 sh -c '
    aws ec2 describe-instances --profile "$1" --region "$2" \
      --instance-ids "$0" --output text --query "Reservations[0].Instances[0].InstanceId" \
      >/dev/null 2>&1 && echo "$1 $2"' "$instance" |
  head -n 1
}

if [ ! -s "$cache/$instance" ]; then
  find_instance > "$cache/$instance" || true
  [ -s "$cache/$instance" ] || { echo "aws-ssm-proxy: $instance not found" >&2; exit 1; }
fi
read -r profile region < "$cache/$instance"

aws ec2-instance-connect send-ssh-public-key \
    --profile "$profile" --region "$region" \
    --instance-id "$instance" --instance-os-user "$user" \
    --ssh-public-key "file://$pubkey" >/dev/null ||
  { rm -f "$cache/$instance"; exit 1; }

exec aws ssm start-session \
    --profile "$profile" --region "$region" --target "$instance" \
    --document-name AWS-StartSSHSession --parameters "portNumber=$port"

The first connection to an instance pays for the search, which is one describe-instances per profile per Region, run in parallel. Every connection after that reads one line from the cache. If the push fails, because the instance was terminated or your access changed, the script drops the cache entry so the next attempt searches again. Where two profiles reach the same account, the first to answer wins; that is fine for connecting, and worth knowing if the two roles carry different permissions.

Only the public key leaves the laptop, so the private half can live in any SSH agent: 1Password, Secretive, a hardware key. Point AWS_SSH_PUBKEY at the matching .pub.

Last, ~/.ssh/config:

Host i-*
    User ec2-user
    IdentityFile ~/.ssh/id_ed25519
    IdentitiesOnly yes
    ProxyCommand ~/.ssh/aws-ssm-proxy %h %r %p
    ControlMaster auto
    ControlPath ~/.ssh/cm-%C
    ControlPersist 10m
    ServerAliveInterval 60

ssh i-0abc123 now works for any instance in any account you have a profile for. ssh ubuntu@i-0def456 works for Ubuntu instances, provided the ec2:osuser condition allows ubuntu as well. Run aws sso login first if your profiles use IAM Identity Center; with expired credentials every lookup fails and the script reports the instance as not found.

The Control* lines matter more than they look. The pushed key lasts 60 seconds, which is plenty for one login. ControlMaster keeps that authenticated connection open for ten minutes after the last session closes, and every ssh, scp or git operation to the same instance in that window reuses it without calling the script, pushing a key, or starting a new Session Manager session.

Reaching a database behind the instance

The same tunnel reaches anything the instance can reach. Session Manager has a document for it that needs no SSH at all:

aws ssm start-session --target i-0abc123 \
  --document-name AWS-StartPortForwardingSessionToRemoteHost \
  --parameters '{"host":["orders.cluster-cxyz.ap-southeast-2.rds.amazonaws.com"],"portNumber":["5432"],"localPortNumber":["5432"]}'

Then psql -h localhost on the laptop connects to the database. The instance’s security group has to be allowed in by the database’s, the same as for anything else running on that instance. Since SSH is already set up, ssh -N -L 5432:orders.cluster-cxyz.ap-southeast-2.rds.amazonaws.com:5432 i-0abc123 does the same job through the config above, region lookup included.

What you give up

Session Manager does not log SSH sessions. It can record session content to S3 or CloudWatch Logs for its own shell sessions, but SSH encrypts everything inside the tunnel, so for SSH and port-forwarding sessions Session Manager has nothing to log. You still get an audit trail of who connected where and when: StartSession and SendSSHPublicKey are both API calls, and CloudTrail records both, including the instance, the OS user and the public key that was pushed. If you need keystrokes, record them on the instance, or use plain Session Manager shells for the people whose sessions you have to keep.

Hybrid nodes need a different key, and cost money. Session Manager reaches on-premises machines registered with Systems Manager, which have mi- IDs, but SendSSHPublicKey accepts only i- instance IDs. For those, the key has to be in authorized_keys already, and the push step in the script has to be skipped. Session Manager costs nothing on EC2 instances, but from 30 September 2026 AWS bills USD$0.05 a session on hybrid nodes, and every ssh that opens a new connection is a session. The ControlMaster lines above keep that number down.

The other ways in

Session Manager with Instance Connect is the default here because it needs nothing in the VPC, nothing listening, and nothing extra on current AMIs, and it reaches any instance in any account from one config. Each alternative trades one of those away for something this setup lacks.

Side by side

  Session Manager + Instance Connect EC2 Instance Connect Endpoint Verified Access Client VPN Bastion host
Agent on the instance SSM Agent none none none none
Inbound rule on the instance none from the endpoint from the Verified Access endpoint from the VPN from the bastion
What you create per VPC nothing one endpoint, the only one allowed an endpoint per application an endpoint and subnet associations an instance to patch
AWS charge none on EC2 none, beyond cross-AZ data transfer per endpoint-hour, plus connections beyond 100 an hour per subnet-association hour and per connection hour the instance
Reaches RDS without an instance ✗ ✗ ✓ ✓ ✗
Keystroke logging ✗ for SSH; ✓ for plain shells ✗ ✗ ✗ on the bastion

EC2 Instance Connect Endpoint is the way in where the agent cannot run: an appliance image, a locked-down build, an instance someone else owns. aws ec2-instance-connect ssh --instance-id i-0abc123 --connection-type eice connects through it, still needing EC2 Instance Connect on the instance or the key pair it launched with. The limits are the cost. Every VPC needs its own endpoint and gets only one, each endpoint carries 20 concurrent connections, every connection ends after an hour, and none of that is adjustable. It suits a handful of VPCs and a small team.

Plain Session Manager shells, without SSH, are the choice when you have to keep what people typed. Session Manager can stream a shell session’s content to S3 or CloudWatch Logs, which it cannot do once SSH encrypts the tunnel. You give up scp, rsync, and every tool that expects an SSH connection.

AWS Verified Access checks each connection against your identity provider, and optionally the device’s security posture, before it reaches anything. It handles SSH, RDP and database connections through a Connectivity Client on the laptop, and it reaches RDS instances, clusters and proxies directly, with no instance in between. It needs an endpoint for each application and charges for every hour each endpoint exists, so it is a team-wide access layer, not a way to reach one instance.

Client VPN puts the laptop on the VPC’s network, so every tool reaches every address the routes allow, the database included. That is its convenience and its risk: a VPN grants a network, where everything above grants a connection to one named thing. It charges for each associated subnet by the hour, and for each connected user by the hour.

A bastion host is what all of the above replace: a public port 22, a machine to patch, and an authorized_keys file to keep current.

Remote Desktop

The same tunnel carries RDP to a Windows instance. Forward the port, then point any RDP client at localhost:56789:

aws ssm start-session --target i-0abc123 \
  --document-name AWS-StartPortForwardingSession \
  --parameters '{"portNumber":["3389"],"localPortNumber":["56789"]}'

Fleet Manager Remote Desktop does the same from the Systems Manager console with no client at all. It is console-only, Windows Server 2012 and later, disconnects after 60 minutes unless renewed or after 10 idle, and allows 5 connections at once per account per Region by default, raisable to 50. EC2 Instance Connect Endpoint forwards RDP too, with open-tunnel --remote-port 3389 and the same one-hour limit. Whichever you use, Windows Server itself allows two simultaneous RDP sessions without extra Remote Desktop licences.

These posts are LLM-aided. Backbone, original writing, and structure by Craig. Research and editing by Craig + LLM. Proof-reading by Craig.