Cloud & Infrastructure

Securing SSH Bastion Hosts: Establishing Private Boundaries

By DexNox Dev Team Published May 20, 2026

Accessing server instances residing in private subnets is a common requirement for troubleshooting database schemas, reading local logs, or inspecting application processes. Historically, security teams deployed a Bastion Host (or jump box)—a single server exposed to the public internet on port 22 that acts as a gateway. Users first SSH into the bastion, and then hop into private backend instances.

However, exposing port 22 to the internet makes the bastion a constant target for brute-force attacks and zero-day scanner probes. Additionally, managing SSH keys across multiple developer teams becomes an administrative burden, and auditing what commands developers execute inside the network is complex.

Modern cloud security architectures resolve this by either hardening traditional bastions to the absolute limit or transitioning to bastion-less connection managers like AWS Systems Manager (SSM) Session Manager.

This guide analyzes host access security patterns, details a hardened sshd configuration, provides a complete Terraform configuration for bastion-less SSM access, compares access models, and lists four common production failure modes with tested mitigations.


Access Architecture: Hardened SSH vs. Bastion-less SSM

Security teams must decide whether to run a physical gatekeeper server or rely on cloud API tunneling:

Secure Instance Access Topologies:

1. Hardened SSH Bastion (Jump Box Model):
[Developer Client] ──► Public Port 22 (SSH) ──► [Hardened Bastion (Public Subnet)]

                                                      ▼ (Internal Route)
                                           [Target Private Instance]

2. AWS SSM Session Manager (Bastion-less Tunnel):
[Developer Client] ──► Public HTTPS API (Port 443) ──► [AWS SSM Service]

                                                             ▼ (SSM Agent Pull)
                                                   [Target Private Instance]
                                                   (Zero inbound ports open!)

1. Traditional Bastion Hardening

If you must maintain a physical bastion host, the server must be locked down:

  • Disable Password Authentication: Force public-key authentication using secure algorithms like Ed25519 or ECDSA. Block legacy RSA keys.
  • Restrict Ingress: Never expose port 22 to 0.0.0.0/0. Restrict access to specific corporate IP CIDRs using Security Groups.
  • Configure Idle Timeouts: Terminate connections automatically if a user leaves a terminal window open.
  • Enable Fail2Ban: Track failed connection attempts and temporarily block matching client IPs.

2. Bastion-less Access: AWS Systems Manager (SSM)

SSM Session Manager eliminates the bastion host entirely. Instead of listening for inbound network connections, a lightweight background daemon (the ssm-agent) runs inside the private instance and establishes an outbound tunnel to the AWS SSM service API using secure HTTPS (port 443).

When a developer initiates a session via the AWS CLI or Console:

  1. The developer authenticates with AWS IAM (which can be protected by corporate Single Sign-On and Multi-Factor Authentication).
  2. The SSM service matches the IAM credentials and establishes a secure websocket channel to the ssm-agent inside the instance.
  3. Traffic is tunneled over HTTPS. No inbound ports (neither 22 nor any other port) need to be opened on the instance’s Security Group, removing the host from internet scanners.

Production Integration Code

Below are the configurations required to secure traditional SSH daemons and provision a bastion-less SSM environment in Terraform.

1. Hardened SSH Daemon Configuration (sshd_config)

Apply these configurations to /etc/ssh/sshd_config on a traditional bastion to disable passwords, enforce timeout limits, restrict cipher lists, and disable port forwarding.

# /etc/ssh/sshd_config

# Protocol Tuning
Protocol 2
Port 22
ListenAddress 0.0.0.0

# Disable Root and Password Logins
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys

# Restrict Cryptographic Algorithms (Exclude legacy ciphers)
KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com
MACs hmac-sha2-512-etm@openssh.com

# Session Timeout Limits
ClientAliveInterval 300 # Ping client every 5 minutes
ClientAliveCountMax 2   # Drop connection if client fails to respond 2 times

# Tunnel Restrictions
AllowTcpForwarding no
X11Forwarding no
MaxAuthTries 3
MaxSessions 2

# User Groups Scoping
AllowGroups developer-ssh-users

2. Terraform Manifest for Bastion-less SSM Access (ssm-access.tf)

This Terraform manifest provisions a private EC2 instance with no inbound security group rules, binds it to an IAM Instance Profile containing SSM permissions, and configures VPC Endpoints to allow communication with the SSM service from private subnets.

# ssm-access.tf

terraform {
  required_version = ">= 1.5.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

# 1. IAM Role for EC2 Instance to Communicate with SSM
resource "aws_iam_role" "ssm_instance_role" {
  name = "ec2-ssm-execution-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ec2.amazonaws.com"
        }
      }
    ]
  })
}

# Attach standard AWS policy for SSM agent execution
resource "aws_iam_role_policy_attachment" "ssm_policy_attach" {
  role       = aws_iam_role.ssm_instance_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

resource "aws_iam_instance_profile" "ssm_instance_profile" {
  name = "ec2-ssm-instance-profile"
  role = aws_iam_role.ssm_instance_role.name
}

# 2. Security Group with ZERO Inbound Rules
resource "aws_security_group" "private_instance_sg" {
  name        = "private-instance-sg"
  description = "Security Group for private instances with no inbound rules"
  vpc_id      = "vpc-0123456789abcdef0" # Target VPC ID

  # Only allow outbound HTTPS traffic to communicate with SSM endpoints
  egress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["10.0.0.0/16"] # VPC CIDR block
  }
}

# 3. Provision the Private EC2 Instance
resource "aws_instance" "private_target" {
  ami                  = "ami-0c55b159cbfafe1f0" # Target Amazon Linux 2 AMI
  instance_type        = "t3.micro"
  subnet_id            = "subnet-0123456789abcdef1" # Private Subnet ID
  iam_instance_profile = aws_iam_instance_profile.ssm_instance_profile.name
  security_groups      = [aws_security_group.private_instance_sg.id]

  metadata_options {
    http_endpoint               = "enabled"
    http_tokens                 = "required" # Enforce IMDSv2
    http_put_response_hop_limit = 1
  }

  tags = {
    Name        = "private-backend-api"
    Environment = "production"
  }
}

# 4. Configure VPC Endpoints for private SSM access (Interface Endpoints)
resource "aws_vpc_endpoint" "ssm" {
  vpc_id              = "vpc-0123456789abcdef0"
  service_name        = "com.amazonaws.us-west-2.ssm"
  vpc_endpoint_type   = "Interface"
  private_dns_enabled = true
  subnet_ids          = ["subnet-0123456789abcdef1"]
  security_group_ids  = [aws_security_group.private_instance_sg.id]
}

resource "aws_vpc_endpoint" "ssmmessages" {
  vpc_id              = "vpc-0123456789abcdef0"
  service_name        = "com.amazonaws.us-west-2.ssmmessages"
  vpc_endpoint_type   = "Interface"
  private_dns_enabled = true
  subnet_ids          = ["subnet-0123456789abcdef1"]
  security_group_ids  = [aws_security_group.private_instance_sg.id]
}

3. Developer CLI Usage Example (ssm-connect.sh)

Developers run this command locally to initiate a shell session on the private target EC2 instance, bypassing the need for SSH keys.

#!/usr/bin/env bash
# ssm-connect.sh

set -euo pipefail

TARGET_INSTANCE_ID="i-0123456789abcdef0" # Replace with actual EC2 Instance ID

echo "Initiating Systems Manager Session to target instance ${TARGET_INSTANCE_ID}..."
aws ssm start-session --target "${TARGET_INSTANCE_ID}"

Remote Access Models Comparison

The table below compares the security postures of different remote server access topologies:

Operational MetricStandard SSH BastionHardened Bastion + MFAAWS SSM Session ManagerGCP Identity-Aware Proxy (IAP)
Inbound Port OpenPort 22 (Public)Port 22 (Scoped)None (Zero inbound open)None
Key ManagementCritical (Public Keys)Critical (Public Keys)None (Single Sign-On)None (Single Sign-On)
Authentication ModeSSH KeySSH Key + TOTP TokenAWS IAM (OIDC / SSO)Google Cloud IAM
Network OverheadLowLowMedium (Websocket tunnel)Medium
Session Command LogsComplex (Requires tty log)Complex (Requires tty log)Automated (S3/CloudWatch)Automated (Cloud Logging)
MFA SupportManualMandatoryEnforced via SSO policyEnforced via Cloud IAM
Client DependenciesSSH ClientSSH Client + MFA AppAWS CLI + Session Manager PluginGoogle Cloud SDK

What Breaks in Production: Failure Modes and Mitigations

Deploying secure host access models exposes organizations to endpoint routing failures, IAM permission lockouts, host disk saturations, and client plugin version mismatches. Below are four common production failure modes and their mitigations.

1. Private EC2 Instances Failing to Register with the SSM Service

After provisioning an EC2 instance in a private subnet and assigning it the correct IAM role, the instance does not appear in the SSM Managed Instances list, preventing connection attempts.

  • Root Cause: Private subnets do not have routes to the public internet. The ssm-agent daemon must call the AWS SSM service APIs. If the VPC does not have the required SSM Interface VPC Endpoints (ssm, ssmmessages, and optionally ec2messages) configured, or if these endpoints do not have private DNS resolution active, the agent cannot resolve or contact the service.
  • Mitigation: Verify that the VPC Endpoints are created inside the target subnet and that private_dns_enabled is set to true (as demonstrated in the Terraform configuration above). This overrides public DNS queries so that internal endpoints resolve to local network interfaces.

2. Session Manager Connection Terminations (IAM KMS Policy Mismatch)

Developers attempt to launch an SSM session. The terminal window opens but immediately closes, returning an Invalid session state: KMS key access denied error.

  • Root Cause: For security compliance, the organization has configured SSM Session Manager to encrypt all session data streams using a KMS Customer Managed Key. If the developer’s IAM role lacks decrypt/encrypt permissions on the KMS key policy, SSM cannot initialize the encrypted stream, causing the connection to fail.
  • Mitigation: Update the KMS key policy to permit authorization verbs (kms:GenerateDataKey, kms:Decrypt) to the engineering IAM groups, and ensure the developers’ IAM policies include matching statement actions.

3. Disk Space Saturation blocking SSH Access on Traditional Bastions

Developers cannot log into the hardened bastion host, with SSH attempts returning connection closed by remote host and console interfaces showing disk utilization at 100%.

  • Root Cause: Hardened bastions are often configured to log all user actions and connection histories to /var/log. If log rotation is misconfigured or a noisy process generates huge log volumes, the filesystem fills up. Since SSH daemons require temp space to write session processes and handle authentication keys, a full disk prevents new connection handshakes.
  • Mitigation: Mount /var/log on a dedicated volume separate from the root partition (/). Implement strict logrotate policies that compress logs daily and delete files older than 30 days.

4. SSM Agent CPU Spikes and Service Outages on Micro Instances

Private instances become slow or unresponsive, and system monitoring tools alert on high CPU usage caused by the amazon-ssm-agent process.

  • Root Cause: Under heavy terminal data transfers (such as running tail -f on a fast-growing log file or executing large file transfers via SSM tunnels), the SSM agent must process, encrypt, and stream high volumes of data packages over the HTTPS websocket. On small instance types (like t3.nano or t3.micro), this encryption processing can consume all available CPU, causing performance degradation.
  • Mitigation: Restrict SSM sessions to terminal command execution. Do not use SSM tunnels to transfer large files. Configure the Systems Manager agent to use system resource control groups (cgroups) to limit the maximum CPU percentage the daemon is permitted to consume.

Frequently Asked Questions

What is the difference between a traditional SSH bastion and AWS SSM Session Manager?

A traditional bastion requires exposing SSH port 22 to the public internet or specific IPs and managing SSH keys. SSM Session Manager requires zero inbound ports open, tunnel traffic through HTTPS, and utilizes IAM for authentication, eliminating SSH key management.

Why do private subnet EC2 instances fail to register with SSM?

Instances in private subnets cannot reach the public SSM service endpoints without an internet connection. To fix this, you must configure VPC Endpoints (interface endpoints) for SSM services and enable private DNS resolution.

How do you enforce SSH connection timeouts on a hardened bastion?

Configure the ClientAliveInterval and ClientAliveCountMax parameters in /etc/ssh/sshd_config to automatically terminate idle sessions after a set duration.

How does SSM Session Manager audit user commands?

SSM Session Manager can be configured to stream all session terminal input and output logs directly to an encrypted Amazon S3 bucket or Amazon CloudWatch Logs group for compliance auditing.


Wrapping Up

Securing instance access requires removing public vulnerabilities. By hardening traditional bastions—disabling password auth, limiting ciphers, and defining idle timeouts—you can protect legacy gateway servers. However, transitioning to a bastion-less model using AWS Systems Manager (SSM) Session Manager represents a major security advancement: it removes all public inbound ports, integrates authentication with IAM, and automates full command logging to secure your private network boundaries.

Frequently Asked Questions

What is the difference between a traditional SSH bastion and AWS SSM Session Manager?

A traditional bastion requires exposing SSH port 22 to the public internet or specific IPs and managing SSH keys. SSM Session Manager requires zero inbound ports open, tunnel traffic through HTTPS, and utilizes IAM for authentication, eliminating SSH key management.

Why do private subnet EC2 instances fail to register with SSM?

Instances in private subnets cannot reach the public SSM service endpoints without an internet connection. To fix this, you must configure VPC Endpoints (interface endpoints) for SSM services and enable private DNS resolution.

How do you enforce SSH connection timeouts on a hardened bastion?

Configure the ClientAliveInterval and ClientAliveCountMax parameters in /etc/ssh/sshd_config to automatically terminate idle sessions after a set duration.

How does SSM Session Manager audit user commands?

SSM Session Manager can be configured to stream all session terminal input and output logs directly to an encrypted Amazon S3 bucket or Amazon CloudWatch Logs group for compliance auditing.