EC2 Instance Connect Endpoint: Private SSH Without a Bastion
EC2 Instance Connect Endpoint is private SSH: OpenTunnel IAM, the endpoint security group as source, and when Session Manager is the better control.
aws ec2 create-instance-connect-endpoint \
--subnet-id subnet-0123456789abcdef0 \
--security-group-ids sg-eice
aws ec2-instance-connect ssh \
--instance-id i-0123456789abcdef0 \
--connection-type eice \
--os-user ec2-user
The first command is the EC2 Instance Connect Endpoint. The second is a client using it: IAM authenticates the tunnel, the endpoint connects to the instance on TCP 22, and a one-time SSH key is pushed for about a minute. No bastion, no public address on the instance. The service is documented in EC2 Instance Connect Endpoint.
Prefer Session Manager when the agent is present. This page is the path for hosts where SSH is actually required. It does not replace IMDSv2 hop limit 1; a shell on the box can still read metadata if the hop limit allows it.
Security groups
The instance never sees your IP. Source is the endpoint ENI.
| Direction | Rule |
|---|---|
| Endpoint SG egress | TCP 22 to the instance SG |
| Instance SG ingress | TCP 22 from the endpoint SG |
Instance SG ingress from 0.0.0.0/0 |
Remove it. The endpoint does not use it |
| Endpoint SG ingress from the internet | Not how clients connect. Do not add it |
Put the endpoint in a private subnet that can route to the instances. One endpoint’s ENI is the source for every instance you allow. A wide instance SG (“the whole VPC on 22”) means any tunnel through this endpoint can reach those instances, and IAM is the only remaining gate.
Client IP preservation is not the control. Do not write a security group for the corporate NAT and expect EICE traffic to match it.
IAM
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "OpenTunnelToAppInstances",
"Effect": "Allow",
"Action": "ec2-instance-connect:OpenTunnel",
"Resource": [
"arn:aws:ec2:us-east-1:123456789012:instance-connect-endpoint/eice-0123456789abcdef0",
"arn:aws:ec2:us-east-1:123456789012:instance/i-0123456789abcdef0"
],
"Condition": {
"NumericEquals": { "ec2-instance-connect:remotePort": "22" }
}
},
{
"Sid": "PushShortLivedKey",
"Effect": "Allow",
"Action": "ec2-instance-connect:SendSSHPublicKey",
"Resource": "arn:aws:ec2:us-east-1:123456789012:instance/i-0123456789abcdef0",
"Condition": {
"StringEquals": { "ec2:osuser": "ec2-user" }
}
}
]
}
remotePort 22 stops the same permission becoming RDP on 3389. ec2:osuser stops the key landing on root if the AMI allows it. DescribeInstances and DescribeInstanceConnectEndpoints are the read calls the CLI makes; they are not permission to connect.
OpenTunnel on * is a bastion for every VPC that has an endpoint. Grant it on the endpoint you created and the instances that group of people should reach. A break-glass role can be broader. The everyday permission set should not be.
CloudTrail records OpenTunnel and SendSSHPublicKey. Alert on OpenTunnel from a principal that is not the operations role, and on CreateInstanceConnectEndpoint outside the network account. An endpoint someone created in a public subnet with a wide instance SG is a new front door.
When not to build it
| Situation | Use |
|---|---|
| SSM agent already running | Session Manager. No port 22 |
| You need a shell “for later” | Do not pre-create the endpoint |
| Vendor AMI, SSH only, no agent | EICE, scoped as above |
| Copying files off the host | Same IAM. scp rides the same tunnel. Still log it |
The SSH daemon has to be running and AuthorizedKeysCommand (Instance Connect) has to be installed on Amazon Linux and Ubuntu AMIs that support it. A hardened image that removed both will accept the tunnel and then reject the key. Test one instance before you delete the bastion.
Deleting the bastion without deleting its security group and its key pair is how the old path stays open. EICE is additive until you remove inbound 22 from 0.0.0.0/0 and retire the bastion role.
Checklist
- Endpoint in a private subnet; instance has no public IP and no SSH from
0.0.0.0/0 - Instance SG allows TCP 22 only from the endpoint SG
-
OpenTunnelscoped to that endpoint and those instances,remotePort22 -
SendSSHPublicKeyscoped to the instance andec2:osuseris not root - Session Manager used instead wherever the SSM agent runs
- CloudTrail alarm on
CreateInstanceConnectEndpointand unexpectedOpenTunnel - Hop limit on the instance is still 1
The endpoint removed the public address. IAM and the endpoint security group are the bastion now.