Introduction
To add MFA to SSH on Ubuntu 24.04, install the libpam-google-authenticator package, run the setup wizard as each user who needs MFA, add the PAM module to /etc/pam.d/sshd, and enable KbdInteractiveAuthentication in a drop-in sshd_config file. The entire setup takes under 15 minutes and requires no restarts beyond the SSH service — your existing sessions stay connected while you configure it.
Multi-Factor Authentication (MFA) is a login mechanism that requires a user to provide two independent credentials: something they know (a password or SSH key) and something they physically have (a TOTP code generated on their device). TOTP stands for Time-based One-Time Password — a six-digit code derived from a shared secret and the current timestamp, regenerated every 30 seconds. Even if an attacker steals a user's SSH key or cracks their password, they cannot log in without the physical device generating the TOTP at that exact moment.
We run MFA on every admin-facing VM at Raff Technologies. In practice, the protection matters most in two scenarios: compromised SSH keys — which happen more often than people admit, especially when developers share keys across machines — and leaked passwords on accounts that still use password authentication. Adding TOTP turns either of those single points of failure into a dead end.
In this tutorial, you will: install the Google Authenticator PAM module on your Raff Ubuntu 24.04 VM, generate a TOTP secret and scan a QR code with your authenticator app, configure PAM and sshd to require the TOTP code on every SSH login, test the configuration safely without closing your existing session, and apply the same setup to additional users. By the end, your server will require both your SSH key and a live TOTP code to authenticate — two factors, two independent attack surfaces to defeat.
Note
This tutorial configures MFA for SSH access specifically. The libpam-google-authenticator module does not require a Google account and works with any TOTP-compatible authenticator app: Google Authenticator, Microsoft Authenticator, Authy, 1Password, Bitwarden, and others.
Step 1 — Install the Google Authenticator PAM Module
SSH into your Raff VM as a user with sudo privileges and update the package index:
bashsudo apt update
Install libpam-google-authenticator, the PAM module that generates and validates TOTP codes:
bashsudo apt install -y libpam-google-authenticator
Verify the installation succeeded and check the installed version:
bashdpkg -l libpam-google-authenticator
Expected output:
ii libpam-google-authenticator 20191231-3 amd64 Two-step verification
The package installs two components: the google-authenticator command-line wizard that sets up a user's TOTP secret, and pam_google_authenticator.so, the PAM module that validates TOTP codes at login time. The PAM module is what actually enforces MFA — the wizard just generates the shared secret and links it to the user's account.
Step 2 — Generate a TOTP Secret for Your User Account
MFA secrets are per-user, not system-wide. Every account that needs MFA must run the setup wizard independently. Start with the user account you SSH in as — typically your personal sudo account.
If you are currently logged in as root, switch to your regular user account first:
bashsu - your-username
Run the Google Authenticator setup wizard:
bashgoogle-authenticator
The wizard asks a series of questions. Here is the recommended answer for each:
Question 1: Do you want authentication tokens to be time-based (y/n)
Answer y. Time-based tokens (TOTP) expire every 30 seconds. Sequential tokens do not expire — they are weaker and unsuitable for server authentication.
After answering y, the wizard displays:
- A large QR code in your terminal
- A secret key (a long alphanumeric string)
- A verification code (the current TOTP)
- Five emergency scratch codes
Before continuing: Open your authenticator app on your phone, tap the + button (or Add account), and choose Scan QR code. Scan the code in your terminal. The app will immediately start showing 6-digit codes that refresh every 30 seconds.
Warning
Write down the five emergency scratch codes and store them somewhere separate from your phone — in a password manager, printed on paper in a secure location, or in a safe. These single-use codes are your only way back into the server if you lose access to your authenticator app. Each code can only be used once.
Continue the wizard:
Question 2: Do you want me to update your "/home/username/.google_authenticator" file (y/n)
Answer y. This writes the secret key to ~/.google_authenticator. PAM reads this file to validate TOTP codes at login.
Question 3: Do you want to disallow multiple uses of the same authentication token? (y/n)
Answer y. This prevents a captured TOTP code from being replayed within its 30-second window. There is no reason to allow token reuse on a production server.
Question 4: By default, a new token is generated every 30 seconds... Do you want to do so (y/n)
Answer y. This allows tokens generated up to 90 seconds before or after the current time to be accepted, which accommodates minor clock drift between the server and your phone. If you keep your server's time synchronized with NTP (Ubuntu 24.04 does this by default via systemd-timesyncd), this tolerance is rarely needed but harmless to enable.
Question 5: If the computer that you are logging into isn't hardened against brute-force login attempts, you can enable rate-limiting for the authentication module. Do you want to enable rate-limiting? (y/n)
Answer y. This limits authentication attempts to 3 per 30 seconds, which defeats automated TOTP brute-force attempts before they can make a statistical dent.
After answering all questions, verify that the secret file was created:
bashls -la ~/.google_authenticator
Expected output:
-r-------- 1 your-username your-username 151 Apr 22 10:14 /home/your-username/.google_authenticator
The permissions are 400 (read-only by owner) — correct. The PAM module will refuse to use a secret file with looser permissions.
Step 3 — Configure PAM to Require the TOTP Code
PAM (Pluggable Authentication Modules) is the Linux framework that controls how authentication works for each service. SSH uses the PAM configuration file at /etc/pam.d/sshd. You need to add a line telling PAM to invoke the Google Authenticator module during SSH authentication.
Back up the current PAM SSH configuration first:
bashsudo cp /etc/pam.d/sshd /etc/pam.d/sshd.bak
Open the file for editing:
bashsudo nano /etc/pam.d/sshd
Add the following line at the very top of the file, before any other auth lines:
auth required pam_google_authenticator.so
The file should begin like this after your edit:
# PAM configuration for the Secure Shell service
auth required pam_google_authenticator.so
# Standard Un*x authentication.
@include common-auth
Save and close the file.
The required keyword means this module must succeed for authentication to continue. If the TOTP code is wrong or missing, login is denied — regardless of whether the SSH key or password is valid.
Tip
You may see some guides use auth required pam_google_authenticator.so nullok — the nullok option allows users without a configured TOTP secret to skip MFA. This is useful during a gradual rollout across multiple users, but do not leave it in place on accounts that you intend to protect. Remove nullok once all target users have completed Step 2.
Should you keep @include common-auth?
That depends on your setup:
- SSH key authentication (recommended): Keep
@include common-authcommented out or leave it as-is. The SSH key itself is the first factor; TOTP is the second. You do not want a password prompt as well. - Password authentication only: Leave
@include common-authin place. The password is the first factor; TOTP is the second.
The sshd_config changes in the next step tell OpenSSH which combination of factors to require.
Step 4 — Configure OpenSSH to Enforce MFA
PAM is now configured to check TOTP codes, but OpenSSH needs to know it should consult PAM for keyboard-interactive challenges — and which combination of authentication methods to require. On Ubuntu 24.04, OpenSSH ships version 9.6p1, where ChallengeResponseAuthentication has been deprecated in favour of KbdInteractiveAuthentication. Use the correct directive to avoid a silent no-op.
Use the /etc/ssh/sshd_config.d/ drop-in directory to keep your changes isolated from the system-managed defaults:
bashsudo nano /etc/ssh/sshd_config.d/mfa.conf
Add the following configuration:
# MFA enforcement — requires SSH key + TOTP code
KbdInteractiveAuthentication yes
UsePAM yes
AuthenticationMethods publickey,keyboard-interactive
Save and close the file.
What each directive does:
| Directive | Value | Effect |
|---|---|---|
KbdInteractiveAuthentication yes | yes | Allows the PAM module to prompt for the TOTP code via keyboard interaction |
UsePAM yes | yes | Tells OpenSSH to use PAM for authentication (should already be enabled; this makes it explicit) |
AuthenticationMethods publickey,keyboard-interactive | publickey,keyboard-interactive | Requires the user to pass BOTH public key authentication AND a keyboard-interactive challenge (the TOTP code) |
The AuthenticationMethods value is the most important line. publickey,keyboard-interactive means the user must pass public key authentication first, then pass the TOTP challenge. An SSH key alone is not enough. A correct TOTP code alone is not enough. Both are required in sequence.
If your users authenticate with password instead of SSH key, change this to:
AuthenticationMethods keyboard-interactive
This requires only the TOTP challenge, which follows the password prompt from @include common-auth in /etc/pam.d/sshd.
Verify the configuration is syntactically valid before restarting:
bashsudo sshd -t
No output means the configuration is valid. If you see errors, fix them before proceeding.
Restart SSH to apply the changes:
bashsudo systemctl restart ssh
Warning
Do not close your current SSH session yet. Open a second terminal window and test login from there first (Step 5). If you have misconfigured something, your existing session keeps you connected while you fix it.
Step 5 — Test MFA Login Without Closing Your Session
Keep your current SSH session open. Open a new terminal window or tab and attempt to connect to your server:
bashssh your-username@<your-server-ip>
If you are using SSH key authentication, the connection flow now has two stages:
Authenticated with partial success.
(your-username@<your-server-ip>) Verification code:
The Authenticated with partial success message confirms your SSH key was accepted and OpenSSH is now asking for the second factor. Open your authenticator app, find the 6-digit code for this server, and type it at the prompt. Do not include spaces.
After entering the correct code, you should land at your shell prompt. MFA is working.
If the connection is rejected with Permission denied (publickey,keyboard-interactive), the most common causes are:
- The TOTP code expired in the time between reading it and typing it — try again immediately with the current code
- The
AuthorizedKeysFilepath in sshd_config points somewhere the chroot or user configuration doesn't expose (only relevant if you also have chroot jails configured) - The
~/.google_authenticatorfile belongs to the wrong user or has wrong permissions — verify withls -la ~/.google_authenticator
Check /var/log/auth.log for detailed PAM error messages:
bashsudo tail -30 /var/log/auth.log
Look for lines like:
Apr 22 10:31:02 raff-vm sshd[5122]: pam_google_authenticator: Invalid verification code
Apr 22 10:31:04 raff-vm sshd[5122]: pam_google_authenticator: Accepted google_authenticator for your-username
The Accepted line confirms PAM validated the TOTP code successfully.
Tip
Test with ssh -v your-username@<your-server-ip> for verbose output that shows every authentication step. You will see Authenticated with partial success followed by keyboard-interactive challenge handling — useful for confirming the exact point any failure occurs.
Step 6 — Enable MFA for Additional Users
Each user who needs MFA must run the setup wizard in their own session. The secret stored in ~/.google_authenticator is user-specific — one user's secret does not affect another's.
For each additional user, have them log in (without MFA yet, since their .google_authenticator file does not exist) and run:
bashgoogle-authenticator
They follow the same wizard steps from Step 2 and scan the QR code with their own authenticator app.
If you are rolling out MFA across a team and want to avoid locking out users who have not set up their secrets yet, add the nullok option to the PAM line temporarily:
bashsudo nano /etc/pam.d/sshd
Change:
auth required pam_google_authenticator.so
To:
auth required pam_google_authenticator.so nullok
With nullok, users without a ~/.google_authenticator file can still log in without a TOTP code. Once all users have completed setup, remove nullok to make MFA mandatory for everyone.
To verify that a specific user has completed setup:
bashsudo ls -la /home/username/.google_authenticator
If the file exists and is owned by that user with permissions 400, they are configured. If the file is missing, they have not run google-authenticator yet.
Step 7 — Harden the MFA Configuration
With MFA working, apply these additional hardening steps to close secondary attack paths.
Disable password authentication entirely
If all your users authenticate with SSH keys plus TOTP, there is no reason to keep password authentication enabled. Password auth is a fallback that attackers can brute-force; SSH keys cannot be brute-forced.
In your drop-in config:
bashsudo nano /etc/ssh/sshd_config.d/mfa.conf
Add:
PasswordAuthentication no
PermitEmptyPasswords no
Protect the emergency scratch codes
The scratch codes generated in Step 2 are stored inside ~/.google_authenticator. The file is already 400, but confirm it:
bashchmod 400 ~/.google_authenticator
Encourage users to store their scratch codes in a password manager, not in a text file on the same server.
Set a short ClientAlive timeout
Idle sessions that stay connected indefinitely are a risk — anyone who walks past an unlocked workstation can use them. Set a timeout in your sshd drop-in:
bashsudo nano /etc/ssh/sshd_config.d/mfa.conf
Add:
ClientAliveInterval 300
ClientAliveCountMax 2
This disconnects idle sessions after 10 minutes of no activity (300 seconds × 2 checks). Active sessions are unaffected.
After any changes to sshd_config.d/mfa.conf, always validate and restart:
bashsudo sshd -t && sudo systemctl restart ssh
Conclusion
Your Raff VM now requires two independent factors to authenticate over SSH: a valid SSH key and a live TOTP code from a physical device. An attacker who steals your SSH key cannot log in without your phone. An attacker who compromises your phone cannot log in without your SSH key. The two factors are independent, and defeating both simultaneously is a substantially harder problem than either alone.
This was tested on a Raff Tier 2 VM (1 vCPU / 2 GB RAM) running Ubuntu 24.04 LTS with OpenSSH 9.6p1. The KbdInteractiveAuthentication directive is required on Ubuntu 24.04 — older tutorials that use the deprecated ChallengeResponseAuthentication directive will silently fail to enforce MFA on this OpenSSH version.
A few things we do at Raff on top of this that are worth considering as next steps:
- Firewall with UFW: Restrict SSH access to specific IP ranges. MFA makes brute-force attacks far harder, but rate-limiting at the firewall level eliminates the noise entirely.
- SFTP-only user access: For users who only need file transfer access, combine chroot jails with MFA — restrict them to SFTP and require TOTP on top.
- Fail2ban: Install
fail2banwith thesshdjail to automatically block IPs that fail authentication repeatedly. Even with MFA, reducing log noise is worth the 5-minute setup.
MFA is one layer in a stack. It works best alongside a properly configured firewall, SSH key rotation, and disabled root login — each of which plugs a different hole.
