How to Add Two-Factor Authentication to SSH
Enforce two-factor authentication on SSH using PAM and Google Authenticator TOTP, with YubiKey alternatives and per-user exemption patterns for automation accounts.
Before you start
- ▸SSH server (openssh-server) installed and running
- ▸sudo or root access on the target server
- ▸An RFC 6238-compatible authenticator app installed on your phone (Aegis, Google Authenticator, Bitwarden, etc.)
- ▸An active SSH session to the server before making changes, to avoid lockout
Password-based SSH is a single point of failure. Two-factor authentication (2FA) adds a second layer: even if a private key or password is stolen, an attacker still needs a time-based one-time password (TOTP) from your authenticator app or a hardware token. This guide walks through enforcing 2FA on SSH using PAM and libpam-google-authenticator, with notes on YubiKey as an alternative.
How It Works
OpenSSH delegates authentication to PAM (Pluggable Authentication Modules) when configured to do so. The pam_google_authenticator.so module intercepts the login flow and prompts for a TOTP code generated by any RFC 6238-compatible app (Google Authenticator, Aegis, Authy, Bitwarden, etc.). The flow you'll build is:
- Public-key authentication passes (or password, depending on your policy)
- PAM then demands the TOTP code
- Both must succeed for the session to open
You can optionally require only TOTP (no password), but the most secure setup chains a key with TOTP.
Step 1 — Install the PAM Module
Debian / Ubuntu
sudo apt update
sudo apt install libpam-google-authenticator
Fedora / RHEL 9 / Rocky Linux
sudo dnf install google-authenticator qrencode
On RHEL-family systems the package is in the EPEL repository. Enable it first if you haven't:
sudo dnf install epel-release
Arch Linux
sudo pacman -S libpam-google-authenticator
Step 2 — Run the Setup as Each User
Every user who will log in via SSH must run this command as themselves, not as root. It writes a secret key and scratch codes to ~/.google_authenticator.
google-authenticator
Answer the interactive prompts deliberately:
- Time-based tokens? — Yes. TOTP is far more common and better supported than HOTP.
- Update .google_authenticator? — Yes.
- Disallow reuse? — Yes. Prevents replay attacks.
- Rate limiting? — Yes. Limits brute-force attempts.
- Permit 30-second window skew? — Answer based on whether your clocks are NTP-synced (yes = strict, no = allows ±1 window). Tight clocks → choose no for stricter security.
The command prints a QR code in the terminal and an emergency scratch codes block. Save the five scratch codes offline immediately — they are single-use recovery tokens.
Scan the QR code with your authenticator app or manually enter the secret key shown beneath it.
Step 3 — Configure PAM for SSH
Edit the PAM configuration for the SSH daemon. The file path differs by distro.
Debian / Ubuntu
sudo nano /etc/pam.d/sshd
Add this line at the end of the file (or after the @include common-auth line):
auth required pam_google_authenticator.so
Fedora / RHEL / Rocky
sudo nano /etc/pam.d/sshd
Add the same line. On these systems the file already contains several auth lines; append yours after them:
auth required pam_google_authenticator.so
Arch Linux
sudo nano /etc/pam.d/sshd
Add the same line at the end.
The required control flag means PAM will always evaluate this module and fail the entire authentication if it returns failure — even if other modules already passed. This is intentional.
Step 4 — Configure sshd
Open /etc/ssh/sshd_config. You need to set (or confirm) three directives:
sudo nano /etc/ssh/sshd_config
Set or update these lines:
ChallengeResponseAuthentication yes
UsePAM yes
AuthenticationMethods publickey,keyboard-interactive
The AuthenticationMethods line enforces that both a public key and keyboard-interactive (which triggers PAM/TOTP) are required. If you want TOTP to supplement passwords instead of keys, use keyboard-interactive alone — but key + TOTP is the stronger policy.
Note: On OpenSSH 8.7+ (Fedora 35+, Ubuntu 22.04+) ChallengeResponseAuthentication was renamed to KbdInteractiveAuthentication. Check your version:
ssh -V
If your version is 8.7 or later, use:
KbdInteractiveAuthentication yes
UsePAM yes
AuthenticationMethods publickey,keyboard-interactive
Both directive names are accepted in many versions for backward compatibility, but use the current name on modern systems to avoid deprecation warnings.
Step 5 — Reload sshd
Always reload, not restart, from an existing session. A configuration error in a restart could lock you out.
sudo systemctl reload sshd
Keep your current SSH session open while testing from a second terminal. Do not close it until you have confirmed login works.
Step 6 — Test the Login
From a second terminal, connect to the server:
ssh -v user@yourserver
You should see your key accepted, then a Verification code: prompt. Enter the 6-digit TOTP from your app. A successful login confirms the chain is working. If login fails, your original session is still open to troubleshoot.
YubiKey as an Alternative
A YubiKey can replace or supplement TOTP. The simplest integration uses the YubiKey's TOTP slot (configured via the YubiKey Authenticator app) — it works with the exact same PAM setup above, because the YubiKey just generates TOTP codes.
For FIDO2/WebAuthn-based SSH authentication (stronger, phishing-resistant), OpenSSH 8.2+ supports sk-ecdsa-sha2-nistp256 and sk-ed25519 key types natively. Generate a resident key directly:
ssh-keygen -t ed25519-sk -O resident -C "yubikey-ssh"
This approach binds the private key to the hardware token entirely — no PAM module needed, and no separate TOTP step. It is the most robust option when hardware tokens are available to all users.
Verification
After a successful test login, confirm PAM is actually enforcing the module by checking auth logs:
Debian / Ubuntu
sudo journalctl -u ssh --since "5 minutes ago"
Fedora / RHEL / Rocky / Arch
sudo journalctl -u sshd --since "5 minutes ago"
Look for lines referencing pam_google_authenticator and Accepted keyboard-interactive. A failed TOTP attempt logs Failed keyboard-interactive.
Troubleshooting
- "Permission denied (publickey)" with no TOTP prompt:
AuthenticationMethodsis set butKbdInteractiveAuthentication/ChallengeResponseAuthenticationis notyes, or sshd was not reloaded. - TOTP codes rejected immediately: Clock skew between server and phone. Run
timedatectl statuson the server to check NTP sync. Phones should also have automatic time enabled. Re-rungoogle-authenticatorand answer yes to the 30-second window prompt for tolerance. - Root lockout: If you enforce TOTP for root, ensure root has run
google-authenticatorand thatPermitRootLoginis set appropriately. Better practice: disable root SSH entirely (PermitRootLogin no) and use sudo from a 2FA-protected regular account. - Service accounts / automation: Automated SSH (rsync, Ansible, CI/CD) breaks if TOTP is enforced globally. Use
Match Userblocks insshd_configto exempt specific accounts:
Match User deploy AuthenticationMethods publickey - Lost phone / codes: Use one of your scratch codes to log in, then re-run
google-authenticatorto generate a new secret.
Frequently asked questions
- Can I require TOTP without also requiring a public key?
- Yes. Set AuthenticationMethods to keyboard-interactive (not publickey,keyboard-interactive) in sshd_config. This prompts for TOTP but not a key, which is weaker than the combined approach.
- How do I exempt a user or service account from TOTP?
- Use a Match User block in sshd_config: 'Match User deploy\n AuthenticationMethods publickey'. This overrides the global AuthenticationMethods for that account only.
- My TOTP codes are always rejected — what's wrong?
- Almost always clock skew. Run 'timedatectl status' on the server and confirm NTP synchronization is active. Also verify your phone's time is set automatically. Re-running google-authenticator and enabling the ±1 window tolerance helps in marginal cases.
- Is the YubiKey FIDO2 method more secure than TOTP?
- Yes. FIDO2 resident keys stored on a YubiKey are phishing-resistant and bind authentication to the physical hardware entirely, with no shared secret that could be extracted from ~/.google_authenticator.
- What happens if I lose my phone and my scratch codes?
- You'll need out-of-band access to the server — physical console, cloud provider web console, or a recovery user that bypasses TOTP. This is why storing scratch codes offline (printed or in a password manager) is non-negotiable.
Related guides
Manage Secrets with Ansible Vault
Encrypt Ansible secrets with AES-256 using ansible-vault: encrypt files and inline vars, automate with password files, and isolate group-level secrets with vault IDs.
AppArmor Explained
Learn how AppArmor profiles work, how to switch between enforce and complain mode, create new profiles, and diagnose access denials on Ubuntu, Debian, and Arch.
Apply CIS Benchmarks with OpenSCAP
Use OpenSCAP and scap-security-guide to evaluate, report on, and remediate Linux systems against CIS Benchmarks — covering install, eval, and automation.
How to Audit a Linux System with auditd
Set up auditd on Linux to track file access, syscalls, and privilege use. Covers persistent rules, file watches, ausearch, and aureport across major distros.