How to Enable Two-Factor Authentication for SSH

Published

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.

Goal: both factors must succeed — SSH key first, then a TOTP code. One missing factor = no shell.

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:

  1. Connect — client opens TCP to sshd and completes the SSH handshake.
  2. Factor 1 — SSH key — OpenSSH verifies the public key (built-in; not PAM).
  3. Factor 2 — TOTP — because AuthenticationMethods requires keyboard-interactive, sshd asks PAM for the next step.
  4. Prompt — client sees Verification code: (wording may vary by client).
  5. Check — PAM compares the code to the secret in ~/.google_authenticator.
  6. Shell — both factors passed → login granted. Either factor fails → connection denied.
Factor 1: publickey (OpenSSH) → Factor 2: keyboard-interactive → pam_google_authenticator.so → shell

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:

Rollout order (do not skip)

  1. Install PAM module
  2. Add PAM line with nullok (optional, for staged enrollment)
  3. Configure sshd_config and run sshd -t
  4. Reload SSH — keep current session open
  5. Enroll TOTP for your user
  6. Test a new SSH connection from another terminal
  7. Remove nullok once 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:

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
AuthenticationMethods publickey,keyboard-interactive → both must succeed, in order

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:

PromptRecommendedWhy
Time-based tokensyesStandard TOTP (RFC 6238); works with all common apps
Update .google_authenticatoryes (first time)Creates the secret file
Disallow multiple usesyesPrevents replay of the same code within the window
Increase time skew windowno (or yes only if clocks drift)Wider window = slightly easier for attackers; use NTP instead
Rate limitingyesThrottles 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

Scan the QR code with your authenticator app. The command also prints:

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)

  1. Keep your current SSH window open
  2. Open a new terminal on your workstation
  3. Connect with verbose logging:
ssh -vvv user@server.example.com

Expected sequence:

  1. Public key authentication succeeds
  2. Prompt: Verification code: (wording may vary)
  3. Enter the 6-digit code from your app
  4. 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

Locked out entirely

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

  1. libpam-google-authenticator installed; module path verified
  2. /etc/pam.d/sshd contains auth required pam_google_authenticator.so (no permanent nullok)
  3. UsePAM yes, KbdInteractiveAuthentication yes
  4. AuthenticationMethods publickey,keyboard-interactive and PasswordAuthentication no
  5. sudo sshd -t passes before every reload
  6. Every SSH user enrolled; ~/.google_authenticator mode 600
  7. Emergency scratch codes stored offline
  8. NTP enabled; timedatectl shows synchronized clock
  9. Tested login from a second session; break-glass path documented
  10. PermitRootLogin prohibit-password (or no)

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.