How to set up an SSH bastion host

A bastion is a small server that accepts SSH from outside and forwards it only to your internal servers. Give its users a group with AllowTcpForwarding local, PermitOpen listing the allowed targets, PermitTTY no and ForceCommand /usr/sbin/nologin, inside a Match Group block, with forwarding off for everyone else. Users then connect with ssh -J user@bastion user@internal. Do not set DisableForwarding yes for them, because that also blocks the jump.

Updated . This config run with OpenSSH 10.0 on Debian 13, using two throwaway containers as bastion and target.

What it should be

  • A small, separate server that runs SSH and nothing else: no database, no website, no developer tools.
  • Keys only, with automatic security updates.
  • No private keys on it. A jump makes this unnecessary. The login to the internal server is encrypted from your own machine, and the bastion just passes the data along.
  • The only way in. Internal servers should accept SSH only from the bastion's address, at the firewall.

For the rest of the server's SSH settings, see how to harden sshd_config.

Create the users

sudo groupadd jump
sudo useradd -m -G jump -s /bin/bash alice
sudo install -d -m 700 -o alice -g alice /home/alice/.ssh
sudo install -m 600 -o alice -g alice alice.pub /home/alice/.ssh/authorized_keys

Keep your own admin account outside the jump group, so that you still have a shell.

The sshd config

# /etc/ssh/sshd_config.d/10-bastion.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
AllowAgentForwarding no
AllowTcpForwarding no
AllowStreamLocalForwarding no
X11Forwarding no
PermitTunnel no
GatewayPorts no
LogLevel VERBOSE

Match Group jump
    AllowTcpForwarding local
    PermitOpen 10.0.1.10:22 10.0.1.11:22
    PermitTTY no
    ForceCommand /usr/sbin/nologin

Each setting in the block has a job:

  • AllowTcpForwarding local allows jumps and -L, but not -R.
  • PermitOpen lists where they can go. List each server: * can stand for a whole host or port, but not for part of an address such as 10.0.1.*.
  • PermitTTY no and ForceCommand remove the shell. ForceCommand alone does not stop forwarding, and that is what allows it to work here.

Keep it in its own file under /etc/ssh/sshd_config.d/, and first check that no other file sets the same options earlier: sshd keeps the first value it reads. A Match block in such a file ends where the file ends. Then check and reload, with a session left open:

sudo sshd -t
sudo sshd -T -C user=alice,host=client,addr=203.0.113.10 | grep -iE '^(allowtcpforwarding|permitopen|permittty|forcecommand) '
sudo systemctl reload ssh
permittty no
allowtcpforwarding local
forcecommand /usr/sbin/nologin
permitopen 10.0.1.10:22 10.0.1.11:22

The nologin path can be /sbin/nologin on other systems, so check it with command -v nologin.

DisableForwarding is for other users

DisableForwarding yes switches off all forwarding and overrides the other settings, so it is handy for a group that must never forward anything. Do not use it for jump users. A jump is a forward, and in our test it failed with stdio forwarding failed.

Connect through it

ssh -J alice@bastion.example.com user@10.0.1.10

Or, to save typing, in ~/.ssh/config on your own machine:

Host bastion
    HostName bastion.example.com
    User alice

Host app1
    HostName 10.0.1.10
    User user
    ProxyJump bastion

Then run ssh app1. Your key must be on both the bastion and the internal server. There is more on the client side in jump hosts and ProxyJump. Avoid ForwardAgent, for the reasons in agent forwarding risks.

Test what is refused

These are the results we got with this config, and you should see the same:

TestResult
ssh -J alice@bastion user@10.0.1.10Logged in to the internal server
ssh -W 10.0.1.10:80 alice@bastionstdio forwarding failed
ssh -tt alice@bastionPTY allocation request failed on channel 0
ssh alice@bastion idThis account is currently not available.
ssh -N -o ExitOnForwardFailure=yes -R 8080:localhost:22 alice@bastionError: remote port forwarding failed for listen port 8080

Logging: what you do and do not get

Already at the default level, each login is logged with the fingerprint of its key, which tells you who connected. LogLevel VERBOSE also logs the keys that were refused. A login looks like this:

Accepted publickey for alice from 203.0.113.10 port 51018 ssh2: ED25519 SHA256:NxFS05Q+k1zf...

Refused targets are logged too:

Received request from 203.0.113.10 port 51036 to connect to host 10.0.1.10 port 80, but the request was denied.

Successful targets are not, at INFO or VERBOSE. We checked: the bastion's log shows that alice connected, but not which server she went on to. In our test that appeared only at LogLevel DEBUG1, and the manual warns that debug levels can record private details of users:

debug1: server_request_direct_tcpip: originator 127.0.0.1 port 65535, target 10.0.1.10 port 22

So read both sides. The internal server's log shows who logged in and with which key, with the bastion's address as the source. Put the two together by time, and send both to a machine outside the bastion, since an attacker with root there could edit the local copy. See how to check login attempts for reading these logs.

Removing access

Take the user's public key off the bastion, and they lose this route to every server behind it. They can still use any internal server that they can reach some other way, so remove the key there too. See how to revoke a key.

Questions

What is the difference between a bastion host and a jump host?

Little. A jump host is any server you pass through to reach another. A bastion is a jump host set up and hardened for that job alone, and it is the only way in from outside.

Does the bastion need my private key?

No. With ProxyJump, the login to the internal server starts on your own machine and is encrypted end to end. The bastion only forwards the connection.

Why does ssh -J fail with stdio forwarding failed?

The bastion refused the forward. Check that AllowTcpForwarding allows it for your user, that the target is listed in PermitOpen, and that DisableForwarding is not set.

Sources

More in Tunnels and jump hosts

All guides