Building an SSH Bastion Host: One Auditable Front Door Into a Private Network

Exposing every server’s SSH port to your admins is an attack surface you do not need. A bastion collapses that surface to one hardened, logged front door — and if you build it properly, your private keys never touch it.

Most bastion setups I inherit are half a bastion. There is a jump box, yes, but the backend servers still accept SSH from anywhere, engineers copy their private keys onto the jump host “to make it easier”, and nobody can tell you who logged into which machine last Tuesday. That is not a control. This piece builds the version that is: backend servers reachable only from the bastion’s address, clients using ProxyJump so keys stay on the laptop, short-lived certificates instead of a sprawl of authorized_keys, and a bastion whose own attack surface is deliberately tiny. The stack is OpenSSH 9.6, ProxyJump, an SSH certificate authority, and nftables.

ProxyJump, not keys on the box

The first thing to get right is that the client, not the bastion, drives the connection. With ProxyJump (the -J flag, in OpenSSH since 7.3) the client opens a connection to the bastion, then asks it to forward a plain TCP channel to the backend, and runs a fresh, end-to-end-encrypted SSH session to the backend inside that channel. The bastion sees an encrypted stream it cannot read. Your private key is used locally, on your machine, to authenticate to the backend. It is never presented to the bastion and never stored on it.

Free · 4 minutes

Would you survive contact with a determined attacker — or an auditor?

Fourteen questions on access, patching, detection, and recovery — the basics that prevent most real incidents, and the ones most often assumed rather than verified. Banded finding on screen, full sheet by email.

This is the point people miss, and it is why agent forwarding is the wrong answer. Forwarding your agent (ssh -A) exposes your agent socket on the bastion; anyone who has rooted the bastion can use it to impersonate you onward. ProxyJump exposes nothing. Put it in the client config and forget it:

# ~/.ssh/config on the engineer's laptop
Host bastion
    HostName bastion.example.com
    User jalice
    IdentityFile ~/.ssh/id_ed25519

Host app-* db-*
    # every host matching these patterns is reached via the bastion
    ProxyJump bastion
    User alice
    IdentityFile ~/.ssh/id_ed25519

Now ssh app-01 transparently jumps through the bastion. No -J to remember, no keys copied anywhere. If you have thought about credential hygiene for machine identities — the same discipline behind API keys that are not a liability — this is the human-identity equivalent: the secret lives in exactly one place and moves as little as possible.

Hardening the bastion’s sshd

Here is the trade-off nobody explains. A hardened SSH server usually sets AllowTcpForwarding no — but ProxyJump is TCP forwarding, so a bastion needs it switched on. The resolution is not to turn forwarding off; it is to allow it and then pin exactly where it may go with PermitOpen, and to deny everything else. Jump users get no shell, no PTY, no agent forwarding — only the ability to reach the two or three backend ports you name.

# /etc/ssh/sshd_config on the bastion
AllowAgentForwarding no
X11Forwarding no
PermitTunnel no
GatewayPorts no
LogLevel VERBOSE

# Jump users are members of the 'jump' group and can do nothing but forward
Match Group jump
    AllowTcpForwarding yes
    PermitOpen 10.0.1.10:22 10.0.1.11:22 10.0.1.12:22
    PermitTTY no
    ForceCommand /usr/sbin/nologin
    AllowAgentForwarding no

The elegant part: ForceCommand only fires when a client requests a session channel — an interactive shell or a remote command. ProxyJump never requests one; it opens a direct-tcpip channel instead. So the forwarding still works, but anyone who runs ssh bastion hoping for a prompt gets nologin and is dropped. PermitOpen means a compromised laptop cannot pivot the bastion into an arbitrary internal port scanner — it can reach 10.0.1.10, .11 and .12 on port 22 and nothing else. LogLevel VERBOSE makes sshd record the authenticating user, source address and public-key fingerprint of every connection, which is the audit trail the whole exercise exists to produce.

Short-lived certificates instead of key sprawl

Distributing public keys into every backend’s authorized_keys does not scale and does not revoke cleanly. An SSH certificate authority fixes both. You sign each engineer’s public key with a CA key, the backends trust the CA, and access is granted by issuing a certificate that expires — no file to edit on the servers, and no revocation to chase because the certificate simply dies.

# One-time: create the user CA (keep this key offline / in an HSM)
ssh-keygen -t ed25519 -f ssh_user_ca -C 'user CA'

# Issue Alice an 8-hour certificate valid only as principal 'alice'
ssh-keygen -s ssh_user_ca \
    -I 'alice@2026-07-22' \
    -n alice \
    -V +8h \
    ~/.ssh/id_ed25519.pub
# produces ~/.ssh/id_ed25519-cert.pub, used automatically by ssh

On every backend (and the bastion), trust the CA with one line:

# /etc/ssh/sshd_config on each host
TrustedUserCAKeys /etc/ssh/ssh_user_ca.pub

The -I identity string and the certificate’s serial are written to the server logs on every login, so “who was on db-02 last Tuesday” becomes a grep, not an investigation. The -V +8h window means a leaked laptop is a problem for a working day, not forever. This is the same argument that governs short-lived bearer tokens — the reasoning behind validating JWTs correctly applies to SSH: bind the credential to a principal, give it a hard expiry, and verify the signer.

Locking the backends to the bastion

None of the above matters if the backends still answer SSH from the wider network. The bastion is only a single front door if it is the only door. Close the rest at the packet filter: on each backend, drop inbound SSH from everything except the bastion’s address.

# /etc/nftables.conf on each backend server
table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;
        ct state established,related accept
        iif "lo" accept
        ip saddr 10.0.0.5 tcp dport 22 accept   # bastion only
        ip protocol icmp accept
    }
}

Apply it with nft -f /etc/nftables.conf and enable the service. Now port 22 on the backends is invisible to the internet and to the rest of the internal estate; the only route in is a certificate-authenticated jump through the bastion, which logs the crossing. On the bastion itself, mirror the discipline in reverse — restrict inbound SSH to your admin ranges or VPN, keep the package set minimal, and run nothing else on it.

Why this is a control a supervisor will accept

For a regulated firm, the value is not the neatness — it is that access to production is now provable. There is one authenticated choke point, every crossing is logged with a named principal and an expiring credential, keys are not scattered across servers or copied onto a shared box, and the network enforces the topology rather than trusting people to honour it. That is the difference between asserting access control and evidencing it, which is precisely what regimes like NIS2 across its uneven transpositions expect you to demonstrate rather than describe.

Build the bastion so that losing it costs an attacker nothing they did not already have — no keys, no shell, no route it did not already permit — and you have a front door worth having. Build it so the box holds your secrets, and you have simply moved all your risk to one machine and put a sign on it.

Free interactive tool

Website compliance checklist

What your site has to do, based on what it actually does

Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.

Can you trust the architecture you have?

Architecture diagrams rarely show the reality of how systems actually operate. An independent review establishes what is really there.