SSH hardening for VPN gateways and bastion hosts
A practical OpenSSH hardening guide for public gateways and bastions, including forwarding policy, PerSourcePenalties, session limits, and safe rollout habits.
Most SSH hardening advice is two-thirds cargo cult and one-third misplaced confidence. The worst example is still "change the port." That reduces log noise. It does not materially improve authentication.
The more serious mistake is subtler: treating an ordinary admin-only server, a bastion host, and a VPN gateway as if they should all share the same sshd_config.
They should not.
On a public bastion, forwarding is often the point. On a backup-only account, forwarding should be impossible. On a minimal gateway, modern OpenSSH already gives you more abuse control than many people realize. The job is not "make SSH strict." The job is "make each account able to do only what it actually needs."
Minimum viable hardening for any public sshd
Start with the baseline that is correct almost everywhere:
- disable password auth
- disable root login
- keep public-key auth on
- keep the user population small
- test config before reload
This is the boring baseline I would ship on a public gateway:
# /etc/ssh/sshd_config.d/00-hardening.conf
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
PermitRootLogin no
AuthenticationMethods publickey
MaxSessions 2
MaxStartups 10:30:60
UnusedConnectionTimeout 2m
Two important details from the canonical sshd_config reference:
PasswordAuthenticationis enabled by default. If you want it off, say so.PermitRootLogindefaults toprohibit-password, which still permits root key login. If you mean "no root SSH," setno.
Do not edit this file and then immediately cut off your only session. Test first:
sshd -t
systemctl reload ssh
ssh -o PreferredAuthentications=publickey admin@vps.example.com
journalctl -u ssh --since -5m
And yes, keep one existing session open until the new one succeeds.
That advice sounds embarrassingly basic right up until the first time someone reloads sshd, loses their only path in, and learns that "hardening" can include hardening the recovery process against themselves.
The directives that actually change risk
AuthenticationMethods
AuthenticationMethods is how you express real multi-factor policy in native OpenSSH. A value like:
AuthenticationMethods publickey,keyboard-interactive
means the user must satisfy both key auth and keyboard-interactive auth in that sequence. That is what "key plus MFA" looks like in stock OpenSSH. It is much more meaningful than cosmetic tweaks.
If you do not have MFA infrastructure yet, key-only auth is still a strong baseline. The point is that your attention should go to real authentication boundaries, not rituals people repeat because they fit in a checklist.
MaxStartups
MaxStartups controls unauthenticated connections and supports the start:rate:full form. The default is 10:30:100, which means OpenSSH will begin random early drop once too many unauthenticated sessions are in flight.
This matters on public gateways because brute-force traffic is mostly a concurrency problem before it becomes a credential problem. Random early drop is not glamorous, but it is the right kind of boring.
MaxSessions
MaxSessions controls how many shell, login, or subsystem sessions can exist per network connection. Setting it to 1 effectively disables connection multiplexing. Setting it to 0 still permits forwarding but no shell or subsystem sessions.
That is more nuanced than people expect, and it matters on bastions. If your jump users need forwarding but you do not want a pile of multiplexed shells, MaxSessions is part of the answer.
UnusedConnectionTimeout
UnusedConnectionTimeout closes authenticated connections that never open a channel. Default is none. That makes sense for compatibility. It is not a great long-lived default on public hosts.
Use something sane, not aggressive. Two minutes is a decent starting point. The docs warn against values so short that clients cannot finish opening channels; believe them.
Timeouts are a good example of what useful hardening looks like. The goal is not to make SSH feel severe. The goal is to reduce useless idle state on a public service without breaking legitimate clients.
Restricted accounts are not jump accounts
The single best OpenSSH hardening switch nobody uses enough is DisableForwarding.
It disables X11, agent, TCP, and StreamLocal forwarding in one place. That is exactly what you want for:
- backup accounts
- maintenance-only service users
- SFTP-only identities
- anything with a forced command
Example:
Match User backup
DisableForwarding yes
PermitTTY no
ForceCommand /usr/local/bin/authorized-backup
That is real risk reduction because it changes what the account can do after auth.
But do not apply that blindly to actual bastion users. A jump host exists to proxy access. Turning off forwarding for bastion users is like hardening a load balancer by refusing to balance load.
A better pattern is to create a narrow group for real jump users:
Match Group jump
AuthenticationMethods publickey
AllowTcpForwarding yes
X11Forwarding no
PermitTunnel no
MaxSessions 1
That is the difference between "SSH everywhere is dangerous" and "this account may proxy TCP because that is literally its job."
If your team uses ProxyJump, this distinction becomes concrete very quickly. Jump users should be allowed to jump. Backup and maintenance identities should not quietly inherit that same capability just because they also happen to authenticate over SSH.
The reason this matters is architectural, not cosmetic. RFC 4254 defines SSH forwarding as part of the SSH connection protocol. Bastion hardening is therefore an access-control problem, not a generic "disable features" problem.
OpenSSH already fights abuse before Fail2Ban ever wakes up
Many operators still act like OpenSSH is a passive daemon waiting to be rescued by external tooling. That is outdated.
Current OpenSSH includes PerSourcePenalties, enabled by default, which applies temporary refusals to client addresses that behave like attackers. The OpenSSH release notes explicitly call out penalties for repeated failures, repeated incomplete auth attempts, and invalid-user behavior.
That matters because cheap Internet attack traffic still behaves exactly that way:
- enumerate usernames
- spray bad passwords or no valid methods
- connect repeatedly without finishing auth
So the hardening order should be:
- correct
sshd_config - modern OpenSSH defaults and penalties
- then external banning tools if needed
If you later add /blog/fail2ban-crowdsec-vpn-servers, it should be reinforcing a sane sshd, not compensating for a lazy one.
The quiet lesson is that modern OpenSSH already has an opinion about abuse. Too many SSH hardening guides still read like the daemon is a dumb socket from a decade ago.
A hardened config that survives contact with reality
Here is the model I like for public gateways:
- one global baseline
Matchblocks for restricted service users- a separate
Matchblock for real jump users - no accidental inheritance of forwarding privileges
For example:
# Global baseline
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
PermitRootLogin no
AuthenticationMethods publickey
MaxStartups 10:30:60
UnusedConnectionTimeout 2m
# Restricted backup account
Match User backup
DisableForwarding yes
PermitTTY no
ForceCommand /usr/local/bin/authorized-backup
# Jump users
Match Group jump
AllowTcpForwarding yes
X11Forwarding no
PermitTunnel no
MaxSessions 1
That is already stricter than most public SSH servers, while still allowing the bastion to function as a bastion.
Verification and rollback
SSH hardening failures are memorable because you only get locked out once before you start being careful.
My rollout loop is:
sshd -tsshd -T | less- reload, not restart
- test a second session
- read the logs
- only then close the original shell
If the host is remote enough that console access is painful, be even more conservative.
One extra habit is worth keeping: test from the same client patterns your real users employ. If your team uses ProxyJump, test ProxyJump. If your bastion users depend on forwarding, test forwarding. A clean ssh admin@host check is necessary, but it does not prove that your jump-host policy still behaves the way production users expect.
And one last blunt opinion: port changes are not hardening. If you want less bot noise, fine, move it. If you want materially stronger security, spend that time on auth methods, forwarding boundaries, session limits, and user scope instead.
That is where the real risk lives on a public bastion.