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
Hostblock? - 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.