Stop exposing port 22 to the world. It’s time to rework your remote access methods

This post is aimed at showing how remote console-based access to cloud-resources can be achieved without exposing port 22 to the world, and – in fact – without any kind of internet access provided to your virtual resources.

This post is based on AWS technologies, which I’m most familiar with, but similar features are being rolled out by the other Cloud Providers.

The idea is to deploy a ‘bastion’ host in a private subnet on AWS and use that as an SSH proxy to access any other hosts in the same VPC and in any other private subnet, without any requirement to expose any port in any of your Security Groups or NACLs to the Internet.

Clearly, this is not meant to be a production-ready deployment, but just serve as inspiration on how things can be done differently.

The infrastructure

We have:

  1. a private subnet where the Bastion host is deployed
  2. another private subnet(s) where target hosts are deployed
  3. a set of VPC Endpoints (Interface-type) to allow the AWS System Manager to reach and be reached by the Bastion SG ssm-agent on port 443
  4. three separate security groups regulating inbound and outbound access to/from the various groups of hosts/interfaces in the infrastructure.

How it all works

AWS System Manager

AWS SSM (System Manager) has a component called ‘Session Manager’ that allows users to gain console-based access to VMs registered to SSM using the AWS API as a transport tunnel.
Essentially, the session interaction data is delivered to the end device via the so-called SSM messages. The advantage of this method, compared to standard SSH is that it does not require the opening of any TCP ports towards the internet, thus reducing the attack surface.
Clearly, something IS exposed to the Internet. And that is the AWS API, which – we trust – is highly secured and protected by layers of filtering and DDoS protection.

Following best-practises to secure the access to the AWS API is particularly important here, more than ever.

AWS VPC Endpoints

But how is the bastion reached by SSM and, viceversa, how does it reach SSM if there’s no public internet access?
This is achieved via VPC Endpoints (Interface). VPC Endpoints create ENIs (Elastic Network Interfaces) in one (or more) subnets that act as a proxy for the service which the Endpoint is being created for.

In this particular instance we are creating VPC endpoints for:

  • SSM Messages: to enable the flow of SSM messages (carrying the user session-interaction data)
  • EC2 Messages: same reason as above
  • SSM: to allow the SSM-agent to register on SSM

More details on the above requirement here:

resource "aws_vpc_endpoint" "ssm_messages" {

  vpc_id            = "${}"

  service_name      = ""

  vpc_endpoint_type = "Interface"

  subnet_ids = aws_subnet.private_subnets.*.id

  security_group_ids = [ ]

  private_dns_enabled = true


resource "aws_vpc_endpoint" "ec2messages" {

  vpc_id            = "${}"

  service_name      = ""

  vpc_endpoint_type = "Interface"

  subnet_ids = aws_subnet.private_subnets.*.id

  security_group_ids = [ ]

  private_dns_enabled = true


resource "aws_vpc_endpoint" "ssm" {

  vpc_id            = "${}"

  service_name      = ""

  vpc_endpoint_type = "Interface"

  subnet_ids = aws_subnet.private_subnets.*.id

  security_group_ids = [ ]

  private_dns_enabled = true


AWS EC2 Instance Profile

An EC2 instance profile is necessary to allow the EC2 instance to make API calls to the AWS SSM without the need of having to store credentials on the instance itself.

resource "aws_iam_role" "ec2_ssm_role" {
  name = "ec2-bastion-ssm-role"
  assume_role_policy = data.aws_iam_policy_document.instance_assume_role_policy.json

data "aws_iam_policy_document" "instance_assume_role_policy" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = [""]

resource "aws_iam_instance_profile" "ec2_ssm_instance_profile" {
  name = "ec2-ssm-profile"
  role =

resource "aws_iam_role_policy_attachment" "ec2_ssm_role_policy_attachment" {
  role       =
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"

Security groups

The security groups are designed so that hosts can accept TCP 22 just from the bastion, to ensure maximum isolation and reduce attack surface.

Custom AMI

The bastion requires a few packages to be present to work properly and tunnel ssh connections.
The first being the amazon-ssm-agent version greater than 2.3.672.0 and the nc package that we’ll use as a socket broker.

I created a custom AMI with both requirements satisfied, as the bastion won’t have internet access to be able to install those later.


The deployment of all the infrastructure is managed by Terraform.
You can have a look at the codebase here.
The Terraform plan produces a host_key.pem file that contains the private ssh key used to ssh into the hosts, and a few outputs indicating the bastion instance ID and the host(s) private_ip addresses.

bastion_instance_id = i-01bbebed1121214e9
host_private_ip =

Before we test this out from our local machine, we need to install the ssm-plugin:

curl "" -o ""


sudo ./sessionmanager-bundle/install -i /usr/local/sessionmanagerplugin -b /usr/local/bin/session-manager-plugin

There is one last thing we need to do if we want to make sure we can open an SSM session to our bastion instance. That is that we need to have our AWS CLI configuration in place. I conveniently use named profiles for that.

SSH over an SSM session

Now we need to configure our SSH client to use the SSM plugin as a transport mechanism via the ProxyCommand directive in the ~/.ssh/config file.

Host i-* mi-*
    ProxyCommand sh -c "aws ssm start-session --target %h --document-name AWS-StartSSHSession --parameters 'portNumber=%p'"

Tunneling SSH to hosts through the bastion

Now it’s time to see if we can ssh into hosts via the bastion.
To do that, we need some extra config in the ~/.ssh/config file.

Host 10.0.*
   ProxyCommand ssh ec2-user@i-01bbebed1121214e9 nc %h %p


In this post I have shown how console-based remote access can be achieved without exposing port 22 externally using a single bastion and multiple target hosts.
Two of the biggest advantages to SSM are that sessions activity can be logged in CloudWatch Logs/S3 and that access can be disciplined using IAM.

The approach described in this post renders those impossible as the tunneled sessions to the end-hosts are not captured, as they’re not terminal activity.

This post’s objective was more to show that it CAN be done, not that it SHOULD be done regardless of specific requirements.

One thought on “Stop exposing port 22 to the world. It’s time to rework your remote access methods

Add yours

Leave a Reply

Please log in using one of these methods to post your comment: Logo

You are commenting using your account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

Blog at

Up ↑

%d bloggers like this: