Two-factor authentication (2FA) for SSH means a login needs something you have (your private key) and a one-time code from an app on your phone. Even if a private key leaks, it is useless without the current code. In this tutorial you will install the Google Authenticator PAM module on Ubuntu 24.04, enrol a user, and configure OpenSSH to require an SSH key followed by a time-based code (TOTP). Any TOTP app works: Google Authenticator, Aegis, 2FAS, Authy or a password manager with TOTP support.

Prerequisites

To follow this tutorial, you need:

  • A server running Ubuntu 24.04 LTS, such as a CubePath VPS.
  • A non-root user with sudo privileges that already logs in with an SSH key. If you still use passwords, set up key authentication first.
  • A smartphone with a TOTP authenticator app.
  • A way into the server that does not depend on SSH, such as the VNC console in your CubePath panel, in case you lock yourself out.

Step 1 - Checking the server clock

TOTP codes are derived from the current time, and the server rejects codes if its clock drifts by more than about a minute and a half. Ubuntu keeps time with systemd-timesyncd; confirm it is synchronised:

timedatectl
               Local time: Thu 2026-09-25 10:20:14 UTC
           Universal time: Thu 2026-09-25 10:20:14 UTC
                 RTC time: Thu 2026-09-25 10:20:14
                Time zone: Etc/UTC (UTC, +0000)
System clock synchronized: yes
              NTP service: active
          RTC in local mode: no

If System clock synchronized is no, enable time synchronisation with sudo timedatectl set-ntp true and check again after a minute. Your phone's clock must be correct too; both Android and iOS set it automatically by default.

Step 2 - Installing the PAM module

The libpam-google-authenticator package provides the PAM module that verifies codes and the google-authenticator command that creates each user's secret:

sudo apt update
sudo apt install libpam-google-authenticator

Confirm the module is in place:

ls /usr/lib/x86_64-linux-gnu/security/pam_google_authenticator.so
/usr/lib/x86_64-linux-gnu/security/pam_google_authenticator.so

On an arm64 server the path is /usr/lib/aarch64-linux-gnu/security/.

Step 3 - Enrolling your user

Each user has their own secret, stored in ~/.google_authenticator. Run the setup as the user who will log in, not with sudo, otherwise the file ends up in root's home:

google-authenticator -t -d -f -r 3 -R 30 -w 3

The flags answer the setup questions non-interactively:

FlagMeaning
-tTime-based codes (TOTP), what authenticator apps expect
-dRefuse to accept the same code twice
-fWrite ~/.google_authenticator without asking
-r 3 -R 30Allow at most 3 login attempts every 30 seconds
-w 3Accept the previous, current and next code to tolerate small clock drift

The command prints a large QR code in the terminal (widen the window if it looks broken), followed by the secret and emergency codes:

Your new secret key is: JBSWY3DPEHPK3PXPQRSTUVWXYZ
Enter code from app (-1 to skip): 482913
Code confirmed
Your emergency scratch codes are:
  18273645
  90817263
  55120394
  76403821
  31190578

Scan the QR code with your authenticator app (or type the secret key manually), then enter the six-digit code the app shows to confirm that both sides agree.

Store the emergency scratch codes somewhere safe outside the server, for example in your password manager. Each one works once in place of a TOTP code if you lose your phone.

Check that the secret file is private to your user:

ls -l ~/.google_authenticator
-r-------- 1 your_user your_user 132 Sep 25 10:24 /home/your_user/.google_authenticator

Step 4 - Configuring PAM for SSH

PAM decides which checks run when SSH asks the system to authenticate a user. By default, /etc/pam.d/sshd pulls in common-auth, which asks for the account password. Since your first factor will be the SSH key, replace the password check with the TOTP check. Make a backup first:

sudo cp /etc/pam.d/sshd /etc/pam.d/sshd.bak
sudo nano /etc/pam.d/sshd

Find the line near the top that reads @include common-auth, comment it out, and add the two auth lines below it:

# Standard Un*x authentication.
#@include common-auth

# TOTP code from Google Authenticator
auth required pam_google_authenticator.so nullok
auth required pam_permit.so

nullok lets users who have not run google-authenticator yet log in with their key only. This allows a gradual rollout; you will remove it in Step 7. The pam_permit.so line is needed for that case, because when a user has no secret file the Google Authenticator module neither succeeds nor fails, and PAM would otherwise deny the login.

Leave the rest of the file (the account and session sections) unchanged.

Step 5 - Configuring the SSH daemon

Now tell OpenSSH to use keyboard-interactive authentication (the method that asks for the code) and to require it after a successful key login. Create a drop-in file:

sudo nano /etc/ssh/sshd_config.d/05-2fa.conf
UsePAM yes
KbdInteractiveAuthentication yes
AuthenticationMethods publickey,keyboard-interactive

AuthenticationMethods publickey,keyboard-interactive means both methods must succeed, in that order. A client without a valid key never reaches the code prompt.

For each option, sshd uses the first value it reads, and the files in /etc/ssh/sshd_config.d/ are read in alphabetical order before the main config. That is why this file is called 05-2fa.conf: if another drop-in (for example 10-hardening.conf or 50-cloud-init.conf) sets KbdInteractiveAuthentication no, this one still wins.

Validate the configuration and check the effective values:

sudo sshd -t
sudo sshd -T | grep -Ei '^(usepam|kbdinteractiveauthentication|authenticationmethods)'
usepam yes
kbdinteractiveauthentication yes
authenticationmethods publickey,keyboard-interactive

If sshd -t prints nothing, the syntax is valid. Restart the service:

sudo systemctl restart ssh

Step 6 - Testing the login

Keep your existing session open and connect from a new terminal on your local machine:

ssh your_user@your_server_ip

After the key is accepted (and its passphrase entered, if the agent does not have it), SSH asks for the code:

(your_user@your_server_ip) Verification code:
Welcome to Ubuntu 24.04.3 LTS (GNU/Linux 6.8.0-84-generic x86_64)

Enter the current code from your app. Then check the other cases:

  • A wrong code must be rejected with Permission denied.
  • One of your scratch codes must work in place of the TOTP code (and is then used up).
  • Connecting without your key, for example with ssh -o PubkeyAuthentication=no your_user@your_server_ip, must fail with Permission denied (publickey) without ever asking for a code.

On the server, successful and failed attempts are logged in the SSH journal:

sudo journalctl -u ssh -n 20
sshd[2143]: Accepted publickey for your_user from 198.51.100.24 port 51422 ssh2: ED25519 SHA256:q3Y0mXq0...
sshd[2143]: Accepted keyboard-interactive/pam for your_user from 198.51.100.24 port 51422 ssh2

Both Accepted lines for the same connection show that both factors were checked.

Step 7 - Enforcing 2FA for every user

While nullok is present, any account without a ~/.google_authenticator file can still log in with a key alone. Ask every user to run the command from Step 3, then check who is still missing a secret:

for home in /home/*; do [ -f "$home/.google_authenticator" ] || echo "missing: $home"; done

Once the list is empty (or contains only accounts you handle below), remove nullok and the pam_permit.so line from /etc/pam.d/sshd:

sudo nano /etc/pam.d/sshd
#@include common-auth

# TOTP code from Google Authenticator
auth required pam_google_authenticator.so

PAM files are read on each login, so no restart is needed. Test again from a new terminal.

Exempting automation accounts

Service accounts used by scripts, such as a backup user, cannot type codes. Exempt them by name at the end of the main configuration file, since a Match block applies to every line that follows it:

sudo nano /etc/ssh/sshd_config
Match User backup
    AuthenticationMethods publickey

Validate and restart with sudo sshd -t && sudo systemctl restart ssh. Restrict such accounts further with from= and restrict options in their authorized_keys so a stolen key is of limited use.

Troubleshooting

Codes are always rejected. Almost always a clock problem: check timedatectl on the server and the time on the phone. Also confirm that you ran google-authenticator as the same user you log in as; sudo journalctl -u ssh shows Failed to read "/home/your_user/.google_authenticator" if the file is missing or unreadable.

SSH asks for a password before the code. @include common-auth is still active in /etc/pam.d/sshd. Comment it out as shown in Step 4.

No code prompt, the key alone logs you in. Check sudo sshd -T | grep -i authenticationmethods. If it shows any, another file sets AuthenticationMethods earlier, or your drop-in is not being read. If the output is correct, the user has no secret file and nullok is still present.

You are locked out. Use a scratch code if you have one. Otherwise log in through the VNC console in your CubePath panel and restore the PAM file with sudo cp /etc/pam.d/sshd.bak /etc/pam.d/sshd, or move /etc/ssh/sshd_config.d/05-2fa.conf away and restart ssh.

Lost phone. Log in with a scratch code, then run google-authenticator again to generate a new secret and new scratch codes; the old secret stops working immediately.

Conclusion

SSH logins on your Ubuntu 24.04 server now require both a valid key and a current TOTP code, rolled out safely with nullok and then enforced for everyone, with an exception for automation accounts. As next steps, block repeated failures with Fail2ban, restrict SSH to trusted addresses with UFW, and keep the emergency scratch codes of every user in a shared, access-controlled password vault.