How to set up an SSH bastion with keys and 2FA on Ubuntu Server 22.04

Linux & Sistemas · By · Published on · Updated on · 9 min read

Front bays of a rack storage device, focus on disk compartments
Detail of storage drives in a rack device, representing server infrastructure Photo: Jakub Zerdzicki / Pexels

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 guides

See also