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
sudoprivileges 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.
ImportantKeep one SSH session open as your sudo user for the whole tutorial and test every change from a second terminal. An open session is not affected by configuration changes.
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:
| Flag | Meaning |
|---|---|
-t | Time-based codes (TOTP), what authenticator apps expect |
-d | Refuse to accept the same code twice |
-f | Write ~/.google_authenticator without asking |
-r 3 -R 30 | Allow at most 3 login attempts every 30 seconds |
-w 3 | Accept 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 withPermission 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.
