How to Enable Two-Factor Authentication for SSH
SSH is usually the most sensitive entry point on a Linux server. This guide adds TOTP-based two-factor authentication with libpam-google-authenticator — configured for SSH key plus a one-time code, with a safe rollout path and real troubleshooting.
Password-only SSH is weak. SSH keys alone are much better, but a stolen private key (laptop loss, CI leak, backup exposure) still grants full shell access. TOTP adds a second factor that expires every 30 seconds and does not travel with the key file.
Part 1 — How SSH, PAM, and TOTP fit together
OpenSSH checks the SSH key itself. The second factor runs through PAM when keyboard-interactive auth is enabled. The module pam_google_authenticator.so validates the 6-digit code against ~/.google_authenticator.
Login sequence with the recommended config:
- Connect — client opens TCP to
sshdand completes the SSH handshake. - Factor 1 — SSH key — OpenSSH verifies the public key (built-in; not PAM).
- Factor 2 — TOTP — because
AuthenticationMethodsrequireskeyboard-interactive,sshdasks PAM for the next step. - Prompt — client sees
Verification code:(wording may vary by client). - Check — PAM compares the code to the secret in
~/.google_authenticator. - Shell — both factors passed → login granted. Either factor fails → connection denied.
Legacy option: ChallengeResponseAuthentication
Older tutorials reference ChallengeResponseAuthentication yes. Modern OpenSSH (8.4+) uses KbdInteractiveAuthentication yes instead. If your distro still ships the old name, enable whichever one man sshd_config documents — not both blindly.
Part 2 — Prerequisites and safe rollout plan
You need:
- Root or sudo on the server
- An existing SSH session you are not willing to lose yet
- A second way in if something goes wrong: cloud console (AWS EC2 Serial Console, Hetzner rescue, Proxmox noVNC, IPMI, etc.)
- Accurate system time (TOTP is time-based — see Part 9)
- An authenticator app: Google Authenticator, Microsoft Authenticator, Authy, 1Password, or any RFC 6238 TOTP app
Rollout order (do not skip)
- Install PAM module
- Add PAM line with
nullok(optional, for staged enrollment) - Configure
sshd_configand runsshd -t - Reload SSH — keep current session open
- Enroll TOTP for your user
- Test a new SSH connection from another terminal
- Remove
nullokonce every enrolled user has a secret
Part 3 — Install the Google Authenticator PAM module
Debian / Ubuntu:
sudo apt update sudo apt install libpam-google-authenticator qrencode
qrencode is optional but prints a scannable QR code in the terminal during enrollment.
RHEL / Rocky / AlmaLinux / Fedora:
sudo dnf install google-authenticator qrencode
Confirm the PAM module exists:
find /lib /lib64 /usr/lib /usr/lib64 \ -name pam_google_authenticator.so 2>/dev/null
Typical paths:
- Debian/Ubuntu:
/lib/x86_64-linux-gnu/security/pam_google_authenticator.so - RHEL family:
/usr/lib64/security/pam_google_authenticator.so
Part 4 — Configure /etc/pam.d/sshd
Edit the SSH PAM stack:
sudo nano /etc/pam.d/sshd
Do not replace the file. Add one line at the top of the auth section (before @include common-auth on Debian/Ubuntu):
# At the top of the auth section — after any comments, before @include common-auth auth required pam_google_authenticator.so
On a minimal RHEL /etc/pam.d/sshd, add it before other auth lines that would satisfy authentication without TOTP.
Why required and placement at the top?
required means failure of this module fails the entire auth stack for that phase. Placing it early ensures the TOTP check runs when keyboard-interactive authentication is triggered. Do not use sufficient here — that can let users skip the second factor depending on stack order.
Using nullok during enrollment
While rolling out to multiple users, you may temporarily allow accounts without a .google_authenticator file to still log in:
auth required pam_google_authenticator.so nullok
Remove nullok when enrollment is complete
nullok means "skip TOTP if the user has no secret configured." That is a bypass for anyone who never enrolled. Use it only during migration, then switch back to:
auth required pam_google_authenticator.so
Part 5 — Configure sshd_config
sudo nano /etc/ssh/sshd_config
Ensure PAM is enabled:
UsePAM yes KbdInteractiveAuthentication yes
Recommended: SSH key + TOTP
This is the production pattern: disable password login, require both a valid key and a TOTP code.
PubkeyAuthentication yes PasswordAuthentication no KbdInteractiveAuthentication yes AuthenticationMethods publickey,keyboard-interactive
On some OpenSSH builds you may need the explicit PAM suffix:
AuthenticationMethods publickey,keyboard-interactive:pam
Check which form your version accepts after editing:
sudo sshd -t && sudo sshd -T | grep authenticationmethods
Why disable PasswordAuthentication?
If passwords stay enabled, an attacker with a stolen key might not need TOTP — or could brute-force passwords as a separate path. With PasswordAuthentication no and AuthenticationMethods publickey,keyboard-interactive, the only path is key first, then TOTP prompt.
Alternative: password + TOTP
If you cannot use SSH keys (legacy environment, jump host policy), you can require password and TOTP:
PasswordAuthentication yes AuthenticationMethods password,keyboard-interactive
This is weaker than key + TOTP (passwords are phishable and replayable within the session handshake). Prefer keys in production.
Other sshd settings worth setting once
# Disable root password login; use a normal user + sudo PermitRootLogin prohibit-password # Optional: limit who can SSH at all AllowUsers deploy admin
Part 6 — Enroll a user and protect the secret file
Log in as the user who will use 2FA (not root unless you intentionally 2FA root). Run:
google-authenticator
Interactive prompts — recommended answers for servers:
| Prompt | Recommended | Why |
|---|---|---|
| Time-based tokens | yes | Standard TOTP (RFC 6238); works with all common apps |
Update .google_authenticator | yes (first time) | Creates the secret file |
| Disallow multiple uses | yes | Prevents replay of the same code within the window |
| Increase time skew window | no (or yes only if clocks drift) | Wider window = slightly easier for attackers; use NTP instead |
| Rate limiting | yes | Throttles brute-force attempts against codes |
Non-interactive enrollment (automation / config management) example:
google-authenticator -t -d -f -r 3 -R 30 -w 3 -e 10 -q
-t— time-based tokens-d— disallow reuse-f— force write (overwrite existing file)-r 3 -R 30— rate limit: 3 attempts per 30 seconds-w 3— allow ±1 time step (90 s total window) for clock skew-e 10— generate 10 emergency scratch codes-q— quiet (no prompts)
Scan the QR code with your authenticator app. The command also prints:
- A
otpauth://URL (manual entry fallback) - Emergency scratch codes — store offline (password manager, printed sheet in a safe). Each scratch code works once.
The secret lives in:
~/.google_authenticator
Lock down permissions — the file contains the TOTP secret:
chmod 600 ~/.google_authenticator chown "$USER:$USER" ~/.google_authenticator
Part 7 — Validate config and reload SSH
Always syntax-check before reload:
sudo sshd -t
No output means the configuration is valid. Any error message means do not reload — fix the line it references first.
Inspect effective authentication settings:
sudo sshd -T | grep -Ei 'usepam|passwordauthentication|kbdinteractiveauthentication|authenticationmethods|pubkeyauthentication'
Reload SSH (service name varies by distro):
# Debian/Ubuntu sudo systemctl reload ssh # RHEL / Fedora / most others sudo systemctl reload sshd
Reload, not restart
reload applies new config to new connections without dropping existing sessions. restart disconnects everyone — avoid it on production unless necessary.
Part 8 — Test from a second session (do not lock yourself out)
- Keep your current SSH window open
- Open a new terminal on your workstation
- Connect with verbose logging:
ssh -vvv user@server.example.com
Expected sequence:
- Public key authentication succeeds
- Prompt:
Verification code:(wording may vary) - Enter the 6-digit code from your app
- Shell prompt appears
If TOTP fails but the key works, you still have the original session to fix PAM or time sync — that is why you never close it until the new login path is verified.
Part 9 — Troubleshooting
TOTP codes always rejected
Check server time first. TOTP depends on synchronized clocks (30-second steps).
timedatectl status
Ensure System clock synchronized: yes and timezone is correct. Fix NTP:
# systemd-timesyncd (common on Ubuntu) sudo timedatectl set-ntp true # or chrony (common on RHEL) sudo chronyc tracking
Skew beyond one window requires either fixing NTP or re-enrolling with a wider -w value (less ideal).
Never prompted for a code
KbdInteractiveAuthenticationnot set toyesAuthenticationMethodsmissing or onlypublickey- PAM line missing or below a module that already satisfies auth
- Client uses an old SSH that skips keyboard-interactive — test with
ssh -vvv
Locked out entirely
- Use provider console / rescue mode / physical access
- Revert
/etc/pam.d/sshdor comment thepam_google_authenticatorline - Run
sshd -t, thensystemctl reload sshd - Fix config before trying again
Verbose client and server logs
ssh -vvv user@server.example.com
# journald sudo journalctl -u ssh -f # or sudo journalctl -u sshd -f
# classic log files (distro-dependent) sudo tail -f /var/log/auth.log # Debian/Ubuntu sudo tail -f /var/log/secure # RHEL
Look for pam_google_authenticator, Failed password, or Authentication refused lines around your attempt timestamp.
SELinux (RHEL family)
If home directories are on NFS or non-standard paths, PAM may fail to read .google_authenticator. Check:
sudo ausearch -m avc -ts recent sudo restorecon -Rv ~/.google_authenticator
Part 10 — Hardening, break-glass, and production checklist
Break-glass account
Keep one non-production admin account without 2FA, restricted by source IP in sshd_config or firewall, console-only, or certificate-based access — for emergencies. Document when to use it. Remove nullok from PAM so unenrolled users cannot bypass TOTP.
Match Address 10.50.0.0/24 User breakglass
AuthenticationMethods publickey
PasswordAuthentication no
Per-user or group exceptions
Match Group noc-2fa
AuthenticationMethods publickey
Use sparingly — every exception is a hole in the 2FA policy.
Production checklist
libpam-google-authenticatorinstalled; module path verified/etc/pam.d/sshdcontainsauth required pam_google_authenticator.so(no permanentnullok)UsePAM yes,KbdInteractiveAuthentication yesAuthenticationMethods publickey,keyboard-interactiveandPasswordAuthentication nosudo sshd -tpasses before every reload- Every SSH user enrolled;
~/.google_authenticatormode600 - Emergency scratch codes stored offline
- NTP enabled;
timedatectlshows synchronized clock - Tested login from a second session; break-glass path documented
PermitRootLogin prohibit-password(orno)
Wrapping up
End state: SSH keys prove who you are; TOTP proves you also hold the authenticator. No application changes — only sshd and PAM. A leaked private key alone is no longer enough for a shell.