How to set up an SSH bastion with keys and 2FA on Ubuntu Server 22.04
Linux & Sistemas · By Kevin Marcondes · Published on · Updated on · 9 min read
This guide shows how to set up an SSH bastion with key-based authentication and two-factor TOTP on Ubuntu Server 22.04. You will create a jump host that requires a public key plus TOTP, reducing the risk of unauthorized access and keeping SSH auditing straightforward.
Prerequisites and bastion architecture
A bastion (jump host) is a centralized server that concentrates administrative access to internal networks and enforces strict policies. In this guide we will require a public key and TOTP (2FA) to log in to the bastion.
Before you begin, make sure of these points. Changes to SSH can lock you out. Keep a session open until everything is working and have console access for emergencies.
If you haven't installed the OS yet, follow the step-by-step instructions in the how to install Ubuntu Server guide.
- Updated Ubuntu Server 22.04 with a public IP.
- sudo access to the server and a recent backup of your SSH configuration.
- Your SSH key already created on your workstation (ed25519 preferred).
- A TOTP app on your phone (Aegis, Google Authenticator, Authy, etc.).
- A list of internal hosts you will access through the bastion.
- Firewall planned to expose only the bastion's port 22.
Update the system, create the access user and install packages
We'll create a dedicated user for the bastion, install OpenSSH and the 2FA module. We'll also enable time synchronization, which is essential for TOTP.
Replace PUBLIC_KEY with your real key. Do not paste example keys.
For firewall hardening details, see the guide to set up a firewall on Ubuntu with UFW.
# 1) Atualize e instale pacotes
sudo apt update && sudo apt -y upgrade
sudo apt install -y openssh-server libpam-google-authenticator qrencode
# 2) Habilite o serviço SSH e NTP
timedatectl set-ntp true
sudo systemctl enable --now ssh
# 3) Crie usuário dedicado (ajuste o nome, se quiser)
USER=bastion
sudo adduser --disabled-password --gecos "" "$USER"
sudo usermod -aG sudo "$USER"
# 4) Adicione sua chave pública ao usuário (substitua pela sua)
PUBLIC_KEY='ssh-ed25519 AAAA... cole_sua_chave_aqui'
sudo -u "$USER" mkdir -p "/home/$USER/.ssh"
echo "$PUBLIC_KEY" | sudo tee "/home/$USER/.ssh/authorized_keys" >/dev/null
sudo chown -R "$USER:$USER" "/home/$USER/.ssh"
sudo chmod 700 "/home/$USER/.ssh"
sudo chmod 600 "/home/$USER/.ssh/authorized_keys"
Enable TOTP with Google Authenticator via PAM
We'll generate the user's TOTP secret and integrate PAM so the code is requested during SSH login. Do this for every administrator who will access the bastion.
The command below is interactive. Store the recovery codes. If you lose the app and these codes you may lose access.
After that, insert the module line into the sshd PAM file. Back up the file before making changes.
# 1) Gere o segredo e códigos TOTP para o usuário (interativo)
sudo -u bastion -H bash -lc 'google-authenticator'
# 2) Faça backup e habilite o módulo no PAM do sshd
sudo cp /etc/pam.d/sshd /etc/pam.d/sshd.bak
# Insere a linha na primeira linha do arquivo PAM
sudo sed -i '1iauth required pam_google_authenticator.so' /etc/pam.d/sshd
# Valide que o arquivo não ficou corrompido (visual)
sed -n '1,10p' /etc/pam.d/sshd
Require key + 2FA in OpenSSH and apply hardening
Now we'll force logins to require both a public key and TOTP. We'll also apply recommended SSH hardening tweaks.
We'll create an include file under sshd_config.d. Do not close your current session until you test a new connection.
Below are the parameters and why each is used. Adjust AllowUsers to the list of authorized users.
| Parameter | Effect | Why |
|---|---|---|
| PasswordAuthentication no | Disables password authentication | Prevents brute-force password attacks |
| AuthenticationMethods publickey,keyboard-interactive:pam | Requires key + TOTP | Enforces two-factor authentication |
| PermitRootLogin no | Disables remote root login | Reduces impact of leaked root credentials |
| MaxAuthTries 3 | Limits auth attempts | Makes brute force harder |
| LoginGraceTime 20s | Short login window | Reduces hanging sessions |
| AllowUsers bastion | Restricts to specific users | Smaller attack surface |
| X11Forwarding no / AgentForwarding no | Blocks unnecessary forwards | Fewer pivot vectors |
| AllowTcpForwarding yes | Allows ProxyJump | Required for the bastion to act as a jump host |
# 1) Arquivo de hardening (incluído pelo sshd)
cat <<'EOF' | sudo tee /etc/ssh/sshd_config.d/10-hardening.conf >/dev/null
KbdInteractiveAuthentication yes
UsePAM yes
PubkeyAuthentication yes
PasswordAuthentication no
AuthenticationMethods publickey,keyboard-interactive:pam
PermitRootLogin no
AllowUsers bastion
MaxAuthTries 3
LoginGraceTime 20s
ClientAliveInterval 300
ClientAliveCountMax 2
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding yes
GatewayPorts no
PermitTunnel no
#Banner /etc/issue.net
EOF
# 2) Teste a configuração e reinicie
sudo sshd -t && sudo systemctl restart ssh
# 3) Teste em um novo terminal antes de encerrar esta sessão
UFW firewall and protection against attempts
Limit exposure of port 22 and apply rate limiting. If possible, allow only known source IPs. Optionally use Fail2ban to block IPs after repeated failures.
For other rules and scenarios, consult the UFW guide linked above. If using a VPN is an option, placing a bastion behind a WireGuard road‑warrior increases security.
# 1) UFW: permitir e aplicar rate limit
sudo ufw allow OpenSSH
sudo ufw limit OpenSSH
# Opcional: restrinja a IPs conhecidos (substitua 203.0.113.10)
# sudo ufw delete allow OpenSSH
# sudo ufw allow from 203.0.113.10 to any port 22 proto tcp
sudo ufw enable
sudo ufw status verbose
# 2) Fail2ban básico para sshd
sudo apt install -y fail2ban
sudo tee /etc/fail2ban/jail.local >/dev/null <<'EOF'
[sshd]
enabled = true
bantime = 1h
findtime = 10m
maxretry = 4
EOF
sudo systemctl restart fail2ban
sudo fail2ban-client status sshd
Configure ProxyJump on the SSH client
Use ProxyJump to transparently reach internal hosts through the bastion. This way you authenticate at the bastion and SSH forwards to the destination.
Create a block for the bastion and another for your internal servers. Adjust HostName, User and IdentityFile to match your environment.
- ProxyJump simplifies access: one command connects to the internal machine.
- Works with keys and 2FA on the bastion without exposing internal hosts.
# No seu computador, configure o SSH
mkdir -p ~/.ssh && chmod 700 ~/.ssh
cat <<'EOF' >> ~/.ssh/config
Host bastion
HostName 198.51.100.10 # IP ou DNS do bastion
User bastion # usuário no bastion
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
ServerAliveInterval 60
Host app-interno
HostName 10.0.0.20 # IP interno do destino
User ubuntu # usuário no host interno
IdentityFile ~/.ssh/id_ed25519
ProxyJump bastion
EOF
chmod 600 ~/.ssh/config
# Teste o salto
ssh app-interno
# Ou explicitamente:
# ssh -J bastion [email protected]
How to confirm it worked and troubleshooting
Use the commands below to check authentication, logs and time. Most TOTP issues are caused by incorrect time or the order of methods in sshd.
If something goes wrong, keep console access. Avoid applying changes without a test window.
- Test with verbosity to see whether it asks for the key and then TOTP.
- Check time with timedatectl; correct time is critical.
- Watching sshd logs live helps identify PAM failures.
- Validate syntax with sshd -t before restarting.
- Fail2ban shows bans and counters.
# Testes de conexão e diagnóstico
ssh -vvv app-interno
# Logs do serviço SSH no bastion
sudo journalctl -u ssh -f
# Validação da configuração
sudo sshd -t
# Sincronismo de tempo
timedatectl status
# Status do Fail2ban
sudo fail2ban-client status sshd
Frequently asked questions
How do I recover access if I lose my TOTP app?
Use one of the emergency codes generated by google-authenticator during setup. They act as one-time backups. If you also lost those codes, you'll need console access to temporarily revert the PAM/SSHD changes or to register a new secret for the user account.
Can I enable 2FA only for some users?
Yes. Using the nullok option in PAM makes TOTP optional, and you can control who has a ~/.google_authenticator file. For maximum security, though, prefer requiring 2FA for all and use AllowUsers to list who may access the bastion.
Does this work with YubiKey or other tokens?
Yes, but the setup differs. Instead of TOTP via google-authenticator you can integrate PAM with WebAuthn/U2F or other modules. That requires different PAM modules; this guide focuses on TOTP because it is simple and widely compatible with SSH clients.
Why is my TOTP rejected even when I enter it correctly?
Most often this is caused by a time mismatch between the server and your phone. Check timedatectl and enable NTP. Also confirm the authentication order in sshd_config and that pam_google_authenticator is being loaded in /etc/pam.d/sshd.
How can I audit who accessed hosts via the jump?
On the bastion, use SSH logs and, if needed, enable structured logging and centralized retention. Combine this with per-user ProxyJump entries instead of shared accounts. You can correlate sessions by public keys and origin IPs for traceability.
Should I disable AgentForwarding on the bastion?
Generally, yes. Disabling AgentForwarding reduces the risk of your keys being used on compromised hosts. ProxyJump does not depend on agent forwarding. Enable it only if strictly necessary and with additional restrictions.
Keep improving your Linux security
Apply the same practices to other services: firewall, VPN and monitoring. See related guides and strengthen your environment.
View more Linux guidesSee also
- Tutorial: Implement Canary releases with Traefik in Docker Compose using headers and weighted routing for gradual PHP deployments
- Tutorial: Configure HAProxy with Let's Encrypt and health checks to load balance traffic between Docker containers on a VPS (step-by-step)
- Hands‑On Tutorial: Implement mTLS Between Nginx and Microservices with smallstep CA in Docker Compose
- Tutorial: configure cgroup v2 and systemd ResourceControl to limit CPU, memory and I/O for Docker containers on Debian