How to Add Two-Factor Authentication to SSH (Keys Plus an Authenticator Code)

SSH keys (tutorial 3) already beat passwords by a mile. But a key is a file, and a stolen laptop is a stolen key. Adding a time-based one-time code — the same six digits an authenticator app shows for your bank — means an attacker needs the key and your phone. Twenty minutes of setup, and an extra five seconds per login that you'll stop noticing within a week.
Quick reference
| Step | What |
|---|---|
| Install | sudo apt install libpam-google-authenticator -y |
| Enrol (as your user) | google-authenticator → scan QR with any TOTP app |
| PAM | /etc/pam.d/sshd → add auth required pam_google_authenticator.so |
| sshd | KbdInteractiveAuthentication yes and AuthenticationMethods publickey,keyboard-interactive |
| Restart | sudo systemctl restart ssh — keep a session open |
| Backup | the emergency scratch codes and ~/.google_authenticator |
Before you start
Keep an SSH session open in a second window the whole time. If the new config is wrong, that window is how you fix it. Make sure the server's clock is synced (timedatectl) — TOTP codes depend on the time.
1. Install and enrol
sudo apt install libpam-google-authenticator -y
google-authenticator
Answer the questions: y to time-based tokens, scan the QR code with Google Authenticator, Authy, Aegis, 1Password or Bitwarden, then y to update the file, y to disallow reuse, n to the wider time window (unless your clocks drift), y to rate limiting.
Write down the emergency scratch codes. Each works once, with no phone.
Repeat for each user who logs in (the file is per-user: ~/.google_authenticator).
2. Tell PAM to ask for the code
sudo nano /etc/pam.d/sshd
At the top, add:
auth required pam_google_authenticator.so nullok
and comment out the line @include common-auth (put # in front) so the system password isn't also asked for. nullok lets users who haven't enrolled yet log in with just their key — remove it once everyone has.
3. Tell sshd to require both
sudo nano /etc/ssh/sshd_config
Set (or add) these lines:
KbdInteractiveAuthentication yes
PasswordAuthentication no
AuthenticationMethods publickey,keyboard-interactive
(Older Ubuntu calls the first one ChallengeResponseAuthentication.) AuthenticationMethods publickey,keyboard-interactive means: key first, then the interactive step, which PAM turns into the code prompt.
Check for typos before restarting:
sudo sshd -t
sudo systemctl restart ssh
4. Test from a new window
ssh john@192.168.1.50
You should get Verification code: after the key is accepted. Enter the six digits from the phone. In. Now, and only now, close the safety window.
Skipping the code on your home network
If you only want the code when connecting from outside, before the auth required line in /etc/pam.d/sshd add:
auth [success=1 default=ignore] pam_access.so accessfile=/etc/security/access-local.conf
and create /etc/security/access-local.conf:
+ : ALL : 192.168.1.0/24
- : ALL : ALL
Connections from the LAN skip the authenticator line; everything else still needs it.
Automated jobs
Scripts that SSH into the server (rsync backups from another machine, say) can't type a code. Give them a dedicated user with nullok still in place and no enrolment, or add a Match User backup block in sshd_config with AuthenticationMethods publickey.
Troubleshooting
No code prompt, logs straight in. nullok is set and the user hasn't enrolled, or AuthenticationMethods isn't set. journalctl -u ssh -f while logging in shows what PAM did.
"Permission denied" even with the right code. The clock. timedatectl on the server; the phone's clock too. Or ~/.google_authenticator has the wrong permissions — it must be 600 and owned by the user.
Locked out. Use the safety session, or the console (tutorial 37): remove the PAM line and restart ssh.
Lost the phone. A scratch code gets you in; then google-authenticator again to enrol a new phone.
Wrapping up
Install, enrol, one line in PAM, three in sshd, test from a second window. Keep the scratch codes somewhere safe. Keys stop guessing; codes stop a stolen key. Together they're as close to unbreakable as a home server login gets.
