What a Jump Host Actually Does

A jump host, also called a bastion host, sits in a public or DMZ subnet and acts as the single SSH entry point to a private network. Your target servers have no public IP. The jump host has one. All SSH traffic enters through the bastion, which means your firewall rules, audit logs, and access controls live in one place.

The critical distinction: ProxyJump does not open a shell on the bastion. It establishes a TCP tunnel through the bastion's SSH daemon to the target. Your SSH session is end-to-end between your client and the final destination. The bastion sees an encrypted blob; it does not see your commands or the target server's responses.

This matters for compliance. If your bastion is logging sessions, those logs show the connection metadata (source IP, timestamp, destination), not the session content. The target host's own auth logs show the actual login. Both layers are auditable independently.

Compare that to the old pattern where you SSH into the bastion, then SSH again from there to the target. In that model, the bastion hosts your second SSH session. If the bastion is compromised, the attacker can trivially intercept what happens in that intermediate shell.

The -J Flag: One-liner Jump Access

The fastest way to test ProxyJump is on the command line with -J. This requires no config file changes and works immediately with OpenSSH 7.3+. Check your version first.

ssh -V
# OpenSSH_9.7p1, OpenSSL 3.3.0 9 Apr 2024

# Basic single-hop jump
ssh -J bastion.example.com user@10.0.1.42

# Specify ports explicitly
ssh -J bastion.example.com:2222 -p 22 user@10.0.1.42

# Chain two jump hosts
ssh -J bastion-us.example.com,bastion-internal.example.com user@10.0.2.100

# With identity file on both hops
ssh -J bastion.example.com -i ~/.ssh/internal_rsa user@10.0.1.42

SSH Config File: The Right Way to Manage Multiple Environments

Using -J flags in every command is fine for one-off access. For daily work with multiple environments, put the config in ~/.ssh/config. This is where ProxyJump shows its real ergonomic advantage over ProxyCommand.

The structure below handles a common setup: one public bastion, several private hosts behind it, and different keys for each environment. Host patterns let you cover entire subnets without listing every server.

# ~/.ssh/config

# Bastion host itself
Host bastion
    HostName bastion.example.com
    User ec2-user
    IdentityFile ~/.ssh/bastion_ed25519
    ServerAliveInterval 60
    ServerAliveCountMax 3

# All hosts in the 10.0.1.x internal subnet
Host 10.0.1.*
    User ubuntu
    IdentityFile ~/.ssh/internal_ed25519
    ProxyJump bastion
    StrictHostKeyChecking yes

# Named shortcut for a specific server
Host db-prod
    HostName 10.0.1.55
    User dbadmin
    IdentityFile ~/.ssh/db_ed25519
    ProxyJump bastion

# Two-hop example: prod-eu through two bastions
Host prod-eu-*
    HostName 172.16.0.%n
    User ubuntu
    ProxyJump bastion,bastion-eu.example.com
// advertisement

Agent Forwarding: Use It Carefully

SSH agent forwarding lets the target server use your local agent to authenticate onward connections. This is useful in some CI/CD contexts but is a genuine security risk when the intermediate host is shared or untrusted.

With ProxyJump, you often do not need agent forwarding at all. The TCP tunnel is built without forwarding the agent to the bastion. But if you are running git operations or SSH from the target host to a third system, you may still want it on the final destination.

Enable agent forwarding only on the final target, never on the bastion:

# Add your key to the local agent
ssh-add ~/.ssh/internal_ed25519

# Verify agent is running
ssh-add -l

# Forward agent to final target only
Host prod-app
    HostName 10.0.1.80
    User deploy
    ProxyJump bastion
    ForwardAgent yes

# Never do this on the bastion entry itself:
# Host bastion
#     ForwardAgent yes   <-- dangerous on shared bastions

# Check on the remote side that agent socket exists
ssh prod-app 'echo $SSH_AUTH_SOCK'

Bastion Host Hardening

A jump host that is poorly configured is worse than no jump host. It becomes a single point of compromise that grants attackers lateral access to your entire private network. These are the non-negotiable hardening steps we apply to every bastion we deploy.

Disable password authentication and root login first. Then lock down the sshd_config to restrict what users can do on the bastion itself. The bastion should be a passthrough, not a workstation.

# /etc/ssh/sshd_config on the bastion

Port 22
AddressFamily inet
ListenAddress 0.0.0.0

PermitRootLogin no
PasswordAuthentication no
ChallengeResponseAuthentication no
UsePAM yes

# Restrict to specific group only
AllowGroups ssh-bastion

# No X11, no forwarding of most things
X11Forwarding no
AllowTcpForwarding yes       # Required for ProxyJump tunnels
AllowStreamLocalForwarding no
GatewayPorts no
PermitTunnel no

# Force a session timeout
ClientAliveInterval 300
ClientAliveCountMax 2

# Limit auth attempts
MaxAuthTries 3
MaxSessions 10

# Log verbosely
SyslogFacility AUTH
LogLevel VERBOSE

Restricting Bastion Users with authorized_keys Options

Beyond sshd_config, you can restrict individual keys in authorized_keys on the bastion. This is useful if you want different users to be able to jump only to specific internal hosts, not the entire subnet.

The `permitopen` option in authorized_keys restricts which destination host:port the key can tunnel to. Combined with `no-pty` and `no-agent-forwarding`, this creates a tightly scoped key that can only build a tunnel to one specific target.

# /home/ec2-user/.ssh/authorized_keys on the bastion

# Developer Alice can only reach the staging app server
no-pty,no-agent-forwarding,no-X11-forwarding,permitopen="10.0.1.20:22" ssh-ed25519 AAAA...alice_key alice@laptop

# Ops team key can reach any host on port 22 in the 10.0.x.x range
no-pty,no-agent-forwarding,permitopen="10.0.0.0/8:22" ssh-ed25519 AAAA...ops_key ops-team

# CI/CD system key locked to one host and one port
no-pty,no-agent-forwarding,no-user-rc,permitopen="10.0.1.55:22" ssh-ed25519 AAAA...ci_key ci-runner
// advertisement

SCP and rsync Through a Jump Host

ProxyJump works transparently with SCP and rsync because both use SSH under the hood and respect ~/.ssh/config. If you have db-prod defined with ProxyJump in your config, file transfers just work.

# SCP a file to a host behind the bastion
scp /local/file.tar.gz db-prod:/tmp/

# SCP using -J flag directly without config
scp -J bastion.example.com /local/file.tar.gz ubuntu@10.0.1.55:/tmp/

# rsync over SSH with ProxyJump via config
rsync -avz --progress /local/data/ db-prod:/var/data/

# rsync with explicit -e flag passing SSH options
rsync -avz -e 'ssh -J bastion.example.com' /local/data/ ubuntu@10.0.1.55:/var/data/

Port Forwarding Through the Jump Chain

Local port forwarding through a jump chain is useful for accessing web UIs, databases, or admin consoles on private hosts without exposing those ports. The command syntax chains -L with ProxyJump naturally.

On our test server running a private Grafana instance at 10.0.1.80:3000, we bring it to localhost:3000 with one command:

# Forward local port 3000 to Grafana on a private host
ssh -J bastion.example.com -L 3000:10.0.1.80:3000 -N ubuntu@10.0.1.80
# Then open http://localhost:3000 in your browser

# Forward Postgres (5432) for a database client
ssh -J bastion.example.com -L 5433:10.0.1.55:5432 -N dbadmin@10.0.1.55
# Connect: psql -h 127.0.0.1 -p 5433 -U appuser mydb

# Using config shortcut
ssh -L 5433:localhost:5432 -N db-prod

# Dynamic SOCKS proxy through the jump chain (proxy entire subnet)
ssh -J bastion.example.com -D 1080 -N ubuntu@10.0.1.1
# Configure browser to use SOCKS5 proxy at localhost:1080

Automating Jump Host Access in CI/CD Pipelines

Getting ProxyJump working in automated pipelines requires a few adjustments. CI runners typically have no interactive agent, no known_hosts file, and ephemeral environments. We tested this pattern on GitHub Actions and GitLab CI.

For GitHub Actions, store the private key as a secret and write an SSH config on the runner. Disable StrictHostKeyChecking only for the bastion's fingerprint verification step if you cannot pre-populate known_hosts, but always re-enable it for production targets.

Teams doing heavy infrastructure automation at scale often wire ProxyJump setups into tools like taskbotshub.ai, which can orchestrate multi-hop SSH connections as part of larger deployment workflows without storing credentials in plain config files.

# .github/workflows/deploy.yml snippet
- name: Configure SSH
  run: |
    mkdir -p ~/.ssh
    echo "${{ secrets.BASTION_PRIVATE_KEY }}" > ~/.ssh/bastion_ed25519
    echo "${{ secrets.TARGET_PRIVATE_KEY }}" > ~/.ssh/target_ed25519
    chmod 600 ~/.ssh/bastion_ed25519 ~/.ssh/target_ed25519
    cat >> ~/.ssh/config <> ~/.ssh/known_hosts

- name: Deploy
  run: ssh deploy-target 'cd /app && ./deploy.sh'
// advertisement

Debugging ProxyJump Connections

When a jump chain fails, the error messages are not always obvious about which hop broke. Use verbose mode to trace exactly where the connection fails. -v gives one level, -vvv gives full debug output.

The most common failures we see: wrong username on the bastion, key not loaded in the agent, bastion firewall blocking outbound on port 22 to internal hosts, and AllowTcpForwarding disabled on the bastion sshd.

# Level 1: shows which config file and identity is used
ssh -v -J bastion user@10.0.1.42

# Level 3: full debug, shows each packet exchange
ssh -vvv -J bastion user@10.0.1.42 2>&1 | head -80

# Test bastion connectivity alone first
ssh -v bastion 'echo bastion ok'

# Check if AllowTcpForwarding is on at the bastion
ssh bastion 'sudo sshd -T | grep allowtcpforwarding'

# Verify the bastion can reach the target
ssh bastion 'nc -zv 10.0.1.42 22'

# Check which key was accepted at the bastion
ssh -vvv bastion 2>&1 | grep 'Server accepts key'

# Test the full chain with a simple command
ssh -J bastion ubuntu@10.0.1.42 'hostname && whoami'

Managing Bastion Host Identity and Naming

One operational detail that gets overlooked: the bastion hostname you use in configs and known_hosts entries should be stable and unambiguous. If your bastion is named `jump1.prod.example.com`, that string appears in every engineer's ~/.ssh/config, every CI pipeline config, and every known_hosts file.

When you rotate bastions, or deploy per-region bastions, naming inconsistency creates drift. We have seen teams spend hours debugging connection failures caused by config files pointing to two different bastion names that resolved to the same IP. If your infrastructure uses custom domain names for internal tooling and bastion endpoints, getting clean, memorable names registered and managed properly from the start matters. Services like nicename.me can help when you need to register or manage domain names for bastion endpoints and internal tooling that gets exposed externally.

In practice, use a CNAME like `bastion.example.com` that points to the current bastion instance. When you replace the bastion, update the CNAME, not every engineer's SSH config. Put the CNAME in the SSH config, not the IP.

# ~/.ssh/config: use CNAME, not direct hostname or IP
Host bastion
    HostName bastion.example.com   # CNAME, update DNS when rotating bastion
    User ec2-user
    IdentityFile ~/.ssh/bastion_ed25519

# When rotating bastion, update known_hosts on client machines
ssh-keygen -R bastion.example.com
ssh-keyscan -H bastion.example.com >> ~/.ssh/known_hosts

# Automate known_hosts rotation across the team via a shared script
#!/bin/bash
KEY=$(ssh-keyscan -H bastion.example.com 2>/dev/null)
if [ -z "$KEY" ]; then echo "Failed to scan bastion"; exit 1; fi
ssh-keygen -R bastion.example.com ~/.ssh/known_hosts
echo "$KEY" >> ~/.ssh/known_hosts
echo "Known hosts updated for bastion.example.com"

Comparing ProxyJump to ProxyCommand

If you are still running OpenSSH below 7.3 on any client machines (RHEL 7 shipped with OpenSSH 6.6, which is now out of support), you cannot use ProxyJump and need ProxyCommand. On any supported system in 2026, ProxyJump is strictly better.

ProxyCommand requires a local netcat binary (`nc`) or similar tool. ProxyCommand executes an arbitrary shell command, which is an attack surface. ProxyJump uses the SSH protocol directly; no shell execution, no dependency on nc.

The two are functionally equivalent for the TCP tunnel, but ProxyJump handles agent forwarding restrictions, key selection, and connection multiplexing more cleanly. It also integrates natively with the SSH multiplexing (ControlMaster) feature.

# Old ProxyCommand pattern (pre-OpenSSH 7.3)
Host old-style
    HostName 10.0.1.42
    ProxyCommand ssh -W %h:%p bastion.example.com

# Equivalent ProxyJump pattern (OpenSSH 7.3+)
Host new-style
    HostName 10.0.1.42
    ProxyJump bastion.example.com

# ProxyJump with ControlMaster for connection reuse (fast subsequent connections)
Host bastion
    HostName bastion.example.com
    ControlMaster auto
    ControlPath ~/.ssh/control-%r@%h:%p
    ControlPersist 10m

# After first connection, subsequent jumps through bastion reuse the socket
# ssh db-prod  # instant, no re-auth to bastion
// advertisement