How To Set Up Multi-Factor Authentication for SSH With Google Authenticator on Ubuntu
Tricknowtech Team 7 min read
Goal
In this tutorial, you will install the Google Authenticator PAM module, generate a per-user TOTP secret, and reconfigure OpenSSH so that logging in requires both your existing SSH key and a current one-time code from an authenticator app.
Prerequisites
An Ubuntu 22.04 or 24.04 server with a non-root user in the sudo group
SSH key-based login already working for that user, with the matching private key on your local machine
A phone or desktop app that generates TOTP codes (Google Authenticator, Authy, 1Password, Bitwarden, etc.)
A second terminal or SSH client you can use to test without closing your current session
Let an AI agent do this for you
Copy a ready-made prompt for an AI coding assistant with terminal access to your server (Claude Code, Cursor, or similar) — it can carry out the steps below for you. Review what it plans to run before it executes anything.
SSH key authentication already blocks most password-guessing attacks, but a key is still a single factor: if a private key is copied off a laptop, pulled from a leaky backup, or used from an agent that's already unlocked, that's enough on its own to get in. This tutorial adds a second factor on top of your existing key — a 6-digit, time-based one-time password (TOTP, RFC 6238) from an app on your phone. The end state is key auth plus a TOTP code, not a replacement for the key. Don't reconfigure this later to accept a password plus TOTP instead — a password is weaker than a key even with a second factor behind it, and it reopens the account to online guessing.
Step 1 — Install the Google Authenticator PAM Module
libpam-google-authenticator is a PAM (Pluggable Authentication Modules) module that implements TOTP checking, and it ships in Ubuntu's default repositories, so a plain apt install is all it takes.
Run the setup wizard as the account you actually SSH in as, not as root unless you genuinely log in over SSH as root. It writes its secret to that user's home directory ($HOME/.google_authenticator), and PAM checks the submitted code against whichever user is authenticating.
bash
google-authenticator
The wizard asks a series of yes/no questions. Start with the first one:
"Do you want authentication tokens to be time-based" — yes. This is what makes each code refresh every 30 seconds, and it's what makes the setup compatible with any standard TOTP app — Google Authenticator, Authy, 1Password, Bitwarden, and others all implement the same RFC 6238 algorithm, so the app you pick doesn't matter.
After that, it prints a QR code and a secret key directly in the terminal.
“The secret key printed here lets any app generate valid codes for your account — treat it like a password. Scan the QR code straight into your authenticator app, or type the secret key in manually if you can't scan it. Don't paste it into chat, email, or a support ticket.”
Scan the code (or enter the secret manually), then look below it: the wizard also prints a set of emergency scratch codes.
Tricknowtech VPS Hosting
Dedicated KVM resources and full root access — deployed in under 60 seconds, no ticket required.
“Save the scratch codes now, somewhere other than this server — a password manager entry works well. Each one lets you log in exactly once if you lose the phone with your authenticator app on it. They're shown only this one time, and without them or the phone you lock yourself out.”
Answer the remaining prompts:
"Do you want me to update your '~/.google_authenticator' file?" — yes. Answering no discards everything you just generated and no code will validate.
"Do you want to disallow multiple uses of the same authentication token?" — yes. This stops a captured code from being replayed a second time within its 30-second window.
"Do you want to... increase the original generation time limit..." — no. The default window already accounts for normal clock drift between your phone and the server.
"Do you want to enable rate-limiting?" — yes. This caps login attempts to 3 every 30 seconds, slowing down anyone trying to brute-force a 6-digit code.
The wizard sets ~/.google_authenticator to mode 600 (owner read/write only) automatically. Confirm it:
bash
ls -l ~/.google_authenticator
It should show -rw------- owned by your user. If it's ever looser than that, fix it immediately — anyone who can read this file can generate valid codes without your phone:
bash
chmod 600 ~/.google_authenticator
Step 3 — Wire the PAM Module Into SSH
PAM is what actually checks the code during login. But the default /etc/pam.d/sshd already pulls in a full authentication stack through an @include common-auth line, and that stack's first real check is pam_unix.so — it prompts for the account's Unix password before anything else runs. Left in place, keyboard-interactive login becomes key + Unix password + TOTP, not the key + TOTP setup this tutorial is building. It's worse than just an extra prompt, too: in Ubuntu's common-auth, that pam_unix.so line is immediately followed by auth requisite pam_deny.so. On the typical account this tutorial assumes — a key-only cloud account with the Unix password locked or unset — pam_unix fails outright, requisite halts the whole stack right there, and pam_google_authenticator.so never even runs. That doesn't just skip the TOTP prompt; it denies the login completely and locks you out of SSH. Comment out the include and put the TOTP module in its place so the keyboard-interactive stage checks only the code.
auth required means this check must succeed for the login to proceed. PAM still evaluates the rest of the auth stack even after a failure here, so a wrong code doesn't produce an observably different response than any other failed check. Leave the other @include lines in /etc/pam.d/sshd — common-account, common-session, and so on — untouched; common-auth is the only one responsible for the password check you're removing.
Step 4 — Require Both a Key and a Code in sshd_config
Ubuntu's sshd_config starts with an Include directive that loads every file in /etc/ssh/sshd_config.d/. OpenSSH uses the first value it finds for each setting, so if a drop-in file already sets one of the directives below, that value wins over anything you add later in the main file. Check for conflicts first:
Also confirm which directive name your OpenSSH build actually uses — KbdInteractiveAuthentication is the current name; ChallengeResponseAuthentication is an older alias still accepted on Ubuntu 22.04 and 24.04, and either works as long as you set the one that's effective:
If your file uses the older name instead, use ChallengeResponseAuthentication yes in place of the first line — they behave the same on these Ubuntu releases. AuthenticationMethods publickey,keyboard-interactive is the line doing the real work: it tells sshd a client must complete publickey first and then keyboard-interactive — the stage where PAM, and therefore the TOTP check, actually runs — rather than accepting either method on its own.
Step 5 — Restart SSH and Test Before Closing Your Session
“Do not close your current terminal yet. If AuthenticationMethods or the PAM config is wrong, this can lock you out of SSH entirely. Keep this session open, restart the service, and confirm a login works from a brand-new terminal first — the same rule you'd follow before disabling SSH password auth.”
bash
sudo systemctl restart ssh
Open a second terminal (or a new tab) and reconnect using the same command you normally use to reach this server — for example:
Only close the first terminal once this second one gets you a working shell.
Step 6 — Verify
A working login now goes through three stages: SSH key exchange, then a "Verification code:" prompt, then the shell. Enter the current 6-digit code from your authenticator app.
Enter any 6 digits that don't match the app — the connection should be rejected with Permission denied.
Reconnect and enter the current code from the app — the login should succeed.
If both of those behave as expected, the server is enforcing key plus TOTP, not either one alone.
Troubleshooting: Login Hangs or Fails With No Verification Code Prompt
The most common failure after these changes is a login that fails immediately after key exchange, or just hangs, without ever showing "Verification code:". This almost always means the two sshd_config settings disagree with each other: AuthenticationMethods lists keyboard-interactive, but KbdInteractiveAuthentication (or ChallengeResponseAuthentication) is still set to no somewhere in the effective config — so the method sshd is requiring isn't actually enabled, and there's no interactive stage left to prompt through.
Check the effective configuration on the server, not just what you typed into the file (a drop-in file under sshd_config.d/ can silently win, as noted in Step 4), fix whichever value is wrong, and restart: