How to Enable Two-Factor Authentication for SSH
20 min read
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.
How to read this guide (start here)
Every code block has a colored label. Do only what the label says.
- Read Diagram, formula, or explanation — do not paste into a terminal.
- Copy file Create this file on disk (path is in the label or first comment). Edit values for your environment.
- Run Paste into a shell on the machine this section describes.
- SQL Paste into a database client (
mariadb/mysql/ ProxySQL admin), not bash. - Sample output A realistic excerpt immediately after an observation command; compare fields and read the Meaning note before continuing.
Follow parts in order unless a part says you can skip it. Change example IPs, passwords, and paths to match your lab.
Suggested path: install → enroll → test a second session before disabling password auth.
Part 1 — How SSH, PAM, and TOTP fit together
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
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
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
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(required during staged enrollment — see Part 4) - Configure
sshd_configand runsshd -t - Enroll TOTP for your user before reload if PAM has no
nullok - Reload SSH — keep current session open
- Test a new SSH connection from another terminal
- Remove
nullokonce every enrolled user has a secret
Part 3 — Install the Google Authenticator PAM module
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
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
Sample output — find ... pam_google_authenticator.so
/usr/lib64/security/pam_google_authenticator.so
Meaning: the PAM stack can load this .so. Empty output means the package is not installed (or the module is under a path you did not search) — sshd will then fail at keyboard-interactive with a PAM error in /var/log/auth.log or journalctl -u ssh. The path in /etc/pam.d/sshd must match this file; you usually write pam_google_authenticator.so without a full path so PAM searches the default security directory.
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
What you should do: COPY files; edit for your host; RUN apply/restart as labeled.
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). During rollout, use nullok so users without a secret are not locked out:
/etc/pam.d/sshd (before other auth lines that bypass TOTP).# At the top of the auth section — after any comments, before @include common-auth auth required pam_google_authenticator.so nullok
nullok, reloading SSH before every user enrolls denies login. Remove nullok only after all accounts have ~/.google_authenticator.grep -r . /etc/ssh/sshd_config.d/ 2>/dev/null || true sudo sshd -T | egrep 'passwordauthentication|kbdinteractiveauthentication|authenticationmethods'
Sample output — grep sshd_config.d and sshd -T (excerpt)
/etc/ssh/sshd_config.d/50-cloud-init.conf:PasswordAuthentication no passwordauthentication no kbdinteractiveauthentication yes authenticationmethods publickey,keyboard-interactive
Meaning: the first line identifies a drop-in that can override the main file; the remaining fields are the effective values. Password login is disabled, keyboard-interactive PAM is enabled, and both key then TOTP are required. If kbdinteractiveauthentication no or authenticationmethods any appears, fix the winning drop-in before reload or 2FA can be bypassed/not prompted.
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:
/etc/pam.d/sshd; remove nullok after all users enroll.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
What you should do: COPY files; edit for your host; RUN apply/restart as labeled.
sudo nano /etc/ssh/sshd_config
Ensure PAM is enabled:
/etc/ssh/sshd_config.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.
/etc/ssh/sshd_config block (key + TOTP).PubkeyAuthentication yes PasswordAuthentication no KbdInteractiveAuthentication yes AuthenticationMethods publickey,keyboard-interactive
On some OpenSSH builds you may need the explicit PAM suffix:
/etc/ssh/sshd_config if the shorter form fails on your OpenSSH version.AuthenticationMethods publickey,keyboard-interactive:pam
Check which form your version accepts after editing:
sudo sshd -t && sudo sshd -T | grep authenticationmethods
Sample output — sshd -t then sshd -T
authenticationmethods publickey,keyboard-interactive
Meaning: sshd -t printed nothing and exited 0 — the config parsed. sshd -T dumps the effective config (after includes). You want authenticationmethods to list publickey then keyboard-interactive, matching PAM TOTP. If -t prints Bad configuration option, do not reload — the running sshd still has the old config. If authenticationmethods is missing or still any, a drop-in under /etc/ssh/sshd_config.d/ is overriding your file.
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
/etc/ssh/sshd_config.# 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
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
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
What you should do: COPY files; edit for your host; RUN apply/restart as labeled.
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'
Sample output — sshd -T effective authentication fields
usepam yes pubkeyauthentication yes passwordauthentication no kbdinteractiveauthentication yes authenticationmethods publickey,keyboard-interactive
Meaning: PAM and public keys are enabled, password auth is closed, and authenticationmethods requires the two factors in order. Any different effective value usually means an included file or Match rule wins; do not reload until this output matches the intended policy.
Reload SSH (service name varies by distro):
# Debian/Ubuntu sudo systemctl reload ssh # RHEL / Fedora / most others sudo systemctl reload sshd
A successful reload is normally silent: success means exit code 0 and no stderr. Use only the service name present on your distro; an unknown-unit error means the new configuration was not loaded.
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)
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
- Keep your current SSH window open
- Open a new terminal on your workstation
- Connect with verbose logging:
ssh -vvv user@server.example.com
Sample output — ssh -vvv user@server.example.com (successful excerpt)
debug1: Offering public key: /home/me/.ssh/id_ed25519 debug1: Server accepts key: /home/me/.ssh/id_ed25519 Authenticated using "publickey" with partial success. debug1: Authentications that can continue: keyboard-interactive Verification code: Authenticated to server.example.com using "keyboard-interactive".
Meaning: “partial success” proves the key passed but another factor is still required; keyboard-interactive is the PAM/TOTP phase. Immediate Permission denied before the prompt means key or AuthenticationMethods trouble; a prompt followed by denial points to TOTP secret, permissions, PAM, or clock skew.
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
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
TOTP codes always rejected
Check server time first. TOTP depends on synchronized clocks (30-second steps).
timedatectl status
Sample output — timedatectl status (relevant fields)
Universal time: Sun 2026-08-30 15:02:11 UTC System clock synchronized: yes NTP service: active RTC in local TZ: no
Meaning: synchronized: yes and active NTP indicate the server clock is suitable for 30-second TOTP windows; UTC itself is fine because TOTP uses epoch time. no or an inactive NTP service can make every correct code fail—repair time sync before changing PAM.
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
Sample output — chronyc tracking after enabling NTP (excerpt)
Reference ID : CB00717B (ntp.example.net) Stratum : 3 System time : 0.000184 seconds slow of NTP time Leap status : Normal
Meaning: a valid reference, low stratum, sub-second offset, and Leap status: Normal show chrony is synchronized. Not synchronised, a zero reference ID, or offset above roughly 30 seconds explains rejected TOTP codes.
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
Sample output — ssh -vvv when PAM rejects TOTP (excerpt)
Authenticated using "publickey" with partial success. debug1: Authentications that can continue: keyboard-interactive Verification code: debug1: Authentications that can continue: keyboard-interactive user@server.example.com: Permission denied (keyboard-interactive).
Meaning: the key succeeded, but PAM rejected the second factor. Check synchronized time, mode/owner of ~/.google_authenticator, the PAM module line, and server logs. If publickey never reports partial success, troubleshoot the key instead.
# journald sudo journalctl -u ssh -f # or sudo journalctl -u sshd -f
Sample output — journalctl -u sshd -f during a failed code (excerpt)
Aug 30 15:08:44 host sshd[21841]: Accepted publickey for deploy from 203.0.113.20 port 51422 ssh2 Aug 30 15:08:48 host sshd[21841]: pam_google_authenticator(sshd:auth): Invalid verification code Aug 30 15:08:48 host sshd[21841]: Failed keyboard-interactive/pam for deploy from 203.0.113.20 port 51422 ssh2
Meaning: timestamp, host, PID, user, source IP/port, and auth method identify the exact attempt. The key passed, then the TOTP module rejected the code. Missing unit output may mean your service is named ssh; “secret file not found” or “failed to read” instead points to enrollment, ownership, permissions, or SELinux.
# classic log files (distro-dependent) sudo tail -f /var/log/auth.log # Debian/Ubuntu sudo tail -f /var/log/secure # RHEL
Sample output — tail -f /var/log/auth.log (excerpt)
Aug 30 15:10:03 host sshd[21907]: pam_google_authenticator(sshd:auth): Accepted google_authenticator for deploy Aug 30 15:10:03 host sshd[21907]: Accepted keyboard-interactive/pam for deploy from 203.0.113.20 port 51501 ssh2
Meaning: both lines share the same PID and confirm PAM accepted the code and sshd completed keyboard-interactive auth for the named source. No new lines usually means the distro logs only to journald or you are watching the wrong file; permission/read errors require sudo.
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
Sample output — ausearch -m avc -ts recent (SELinux denial excerpt)
type=AVC msg=audit(1788102730.221:441): avc: denied { open } for pid=22012 comm="sshd" path="/home/deploy/.google_authenticator" scontext=system_u:system_r:sshd_t:s0 tcontext=unconfined_u:object_r:user_home_t:s0 tclass=file permissive=0
Meaning: comm="sshd" was denied open on the secret; scontext/tcontext show the process and file labels, and permissive=0 means enforcement blocked access. Empty output means no recent AVC denial. restorecon is normally silent or prints relabelled paths; persistent NFS/custom-home denials need the correct SELinux boolean/policy, not disabling SELinux.
Part 10 — Hardening, break-glass, and production checklist
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
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.
/etc/ssh/sshd_config (break-glass stanza).Match Address 10.50.0.0/24 User breakglass
AuthenticationMethods publickey
PasswordAuthentication no
Per-user or group exceptions
sshd_config.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
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
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.