OpenSSH: Using a Bastion Host

Published · Updated · System Administration

A bastion host is one of those pieces of infrastructure that is easy to explain and surprisingly easy to get slightly wrong. You have a private server, database node, router, lab machine, or management host that should not be reachable from the open internet. Instead of exposing SSH on every internal system, you expose one hardened jump host and make administrators pass through it.

That is the right shape for a lot of environments. It narrows the public attack surface, gives you one obvious place to enforce MFA or stronger logging, and keeps casual scans away from the machines that actually run the service.

The mistake is treating the bastion as just an annoying extra shell hop:

ssh bastion
ssh internal-host

That works, but it breaks muscle memory. It also makes scp, rsync, Git over SSH, Ansible, and ad hoc one-liners more awkward than they need to be. OpenSSH can do the jump for you. These days, the default answer is ProxyJump.

The Modern Answer: ProxyJump

For a one-off connection, use -J:

ssh -J alice@bastion.example.com alice@app01.internal.example.com

That tells OpenSSH to connect to bastion.example.com, then open the final SSH connection to app01.internal.example.com through that bastion. From your point of view, it still feels like one ssh command. Authentication to the bastion and authentication to the target host remain separate.

For daily use, put the rule in ~/.ssh/config:

Host *.internal.example.com
    User alice
    ProxyJump bastion.example.com

Host bastion.example.com
    User alice
    HostName bastion.example.com

Now this works:

ssh app01.internal.example.com

So do tools that use OpenSSH underneath:

scp config.yaml app01.internal.example.com:/tmp/
rsync -av ./deploy/ app01.internal.example.com:/srv/app/
git clone git@app01.internal.example.com:platform/tools.git

That is the part worth caring about. A good bastion setup should improve your security posture without forcing everyone into a weird bespoke workflow.

Use Host Patterns Carefully

The original version of this article used Host *, which was a quick way to make every SSH connection go through the bastion. That is occasionally useful in a sealed environment, but it is usually too broad.

Prefer a specific pattern:

Host *.prod.example.com *.stage.example.com
    User alice
    ProxyJump bastion.example.com

Or use aliases when the internal names are not resolvable from your laptop:

Host prod-app-01
    HostName 10.20.30.41
    User alice
    ProxyJump bastion.example.com

That second form is common when the bastion can resolve or route to the private address, but your workstation cannot. The important detail is that the final target is interpreted from the bastion side of the connection. If 10.20.30.41 only makes sense inside the private network, that is fine.

Multi-Hop Bastions

Sometimes there is more than one jump. Maybe you enter through a corporate bastion, then hop to a production-management segment. OpenSSH supports a comma separated jump list:

Host *.prod.internal
    User alice
    ProxyJump edge-bastion.example.com,prod-bastion.internal

That is convenient, but do not let convenience hide a design smell. Multiple bastions can be perfectly reasonable in regulated or segmented environments. They can also mean nobody has cleaned up the network model in five years. If you need three jumps to reach an ordinary application host, it is worth asking whether the access path is intentionally segmented or just historically complicated.

Be Careful With Agent Forwarding

The old quick-and-dirty pattern looked like this:

Host *
    ProxyCommand ssh -A <bastion_host> nc %h %p

There are two problems with treating that as the default in 2026.

First, ProxyJump is clearer and does not require nc on the bastion host. Second, -A enables SSH agent forwarding. Agent forwarding is powerful, but it is not harmless. When you forward your agent to a host, processes on that host can ask your local agent to sign authentication challenges while the forwarding session is active. They do not get a copy of your private key, but they may be able to use your agent if the bastion or your session is compromised.

My default recommendation: do not turn on agent forwarding globally.

If you need it for a specific workflow, scope it tightly:

Host deploy-runner
    HostName deploy01.internal.example.com
    User alice
    ProxyJump bastion.example.com
    ForwardAgent yes

Leave it off elsewhere:

Host *
    ForwardAgent no

Better yet, design the workflow so the target host does not need your personal agent at all. Use deploy keys, short-lived credentials, a CI runner with a scoped identity, or a proper secrets workflow. Forwarding your agent through a bastion because it is faster than fixing deployment identity is the kind of shortcut that ages badly.

When ProxyCommand Still Matters

ProxyCommand is not obsolete. It is still useful when you need custom transport behavior, old client compatibility, or a non-standard proxy command. For example, this is the older netcat-style approach:

Host *.internal.example.com
    User alice
    ProxyCommand ssh bastion.example.com nc %h %p

Some environments use ssh -W instead of nc:

Host *.internal.example.com
    User alice
    ProxyCommand ssh bastion.example.com -W %h:%p

The -W form asks SSH on the bastion connection to forward standard input and output directly to the target host and port. It avoids depending on netcat being installed on the bastion. If you are maintaining old configuration, this is often the first cleanup I would make before switching the whole thing to ProxyJump.

For new configuration, start with ProxyJump. Reach for ProxyCommand when you have a specific reason.

Keep The Bastion Boring And Locked Down

A bastion host should not be a general-purpose development box. The more software you install on it, the more interesting it becomes to attackers and the more operational state you have to care about.

At minimum, I want a bastion to have:

  • SSH keys or certificate-based authentication instead of password-only access.
  • MFA or an upstream identity-aware access control when the environment supports it.
  • Tight inbound firewall rules.
  • Minimal user accounts and no shared personal accounts.
  • Good SSH logging, shipped somewhere outside the bastion.
  • Fast patching expectations.
  • No long-lived private keys sitting on disk for convenience.

That last point matters. The bastion is a transit point, not a treasure chest. Do not copy your private SSH key there so you can make the second hop. Use ProxyJump from your workstation, or use scoped credentials meant for the specific automation path.

If your SSH connections are slow or flaky, fix that separately instead of working around it with looser access. I wrote about diagnosing macOS SSH connection delays because the boring plumbing around DNS, IPv6, and server-side lookups can waste a surprising amount of time.

A Practical SSH Config Template

Here is a compact starting point I would be comfortable handing to an engineer:

Host bastion
    HostName bastion.example.com
    User alice
    IdentitiesOnly yes
    IdentityFile ~/.ssh/id_ed25519
    ForwardAgent no

Host *.internal.example.com
    User alice
    ProxyJump bastion
    IdentitiesOnly yes
    IdentityFile ~/.ssh/id_ed25519
    ForwardAgent no

Then connect normally:

ssh app01.internal.example.com

For a host with a private IP address:

Host db-maintenance
    HostName 10.30.40.25
    User alice
    ProxyJump bastion
    ForwardAgent no

And then:

ssh db-maintenance

If you use different keys for bastion access and internal host access, make that explicit:

Host bastion
    HostName bastion.example.com
    User alice
    IdentityFile ~/.ssh/id_ed25519_bastion
    IdentitiesOnly yes

Host *.internal.example.com
    User alice
    ProxyJump bastion
    IdentityFile ~/.ssh/id_ed25519_internal
    IdentitiesOnly yes

Being explicit keeps the client from offering a pile of keys and tripping server limits such as MaxAuthTries. It also makes reviews easier. When someone opens the config six months later, they can see which identity is supposed to be used where.

Troubleshooting Bastion Host Connections

When a bastion connection fails, split the path into pieces.

First, test the bastion:

ssh -v bastion.example.com

Then test the target through the jump:

ssh -v -J bastion.example.com app01.internal.example.com

If DNS only works from inside the private network, test with the name exactly as the bastion should see it. If routing only works from the bastion, do not expect your laptop to ping or resolve the private host directly.

Use -vvv when -v is not enough, but do not start there unless you enjoy reading a novel in debug logs. The useful questions are usually simple:

  • Did the client match the expected Host block?
  • Can I authenticate to the bastion?
  • Can the bastion reach the target host and port?
  • Am I accidentally forwarding through the wrong bastion?
  • Is agent forwarding disabled or enabled where I expect?
  • Is the target rejecting my key before trying the right one?

You can inspect the fully expanded SSH configuration with:

ssh -G app01.internal.example.com

That is a nice sanity check when a pattern match is not doing what you think.

For exposed SSH services, pair bastion design with boring network hygiene. A jump host does not replace rate limits, firewall rules, or log review. Related older notes on limiting SSH connections with ufw and rate-limiting SSH with iptables are still useful as historical examples of the same principle: keep the public edge small, noisy failures contained, and administrative access observable.

The Short Version

For modern OpenSSH bastion host configuration, use ProxyJump:

Host *.internal.example.com
    User alice
    ProxyJump bastion.example.com

Avoid broad Host * rules unless you really mean them. Do not enable agent forwarding globally. Use ProxyCommand only when you need custom behavior or old compatibility. Keep the bastion boring, patched, logged, and free of long-lived private keys.

The best bastion setup is the one your team actually uses because it fits normal SSH workflows. Security controls that require everyone to remember a special ritual eventually become folklore. Put the policy in ~/.ssh/config, keep the access path boring, and let OpenSSH do the jumping.

More practical systems notes live at Slaptijack.

Slaptijack's Koding Kraken