How to Enable Two-Factor Authentication for SSH

20 min read

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.

How to read this guide (start here)

Every code block has a colored label. Do only what the label says.

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 this part is for: cover: 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:

  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

What this part is for: cover: Prerequisites and safe rollout plan.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

You need:

Rollout order (do not skip)

  1. Install PAM module
  2. Add PAM line with nullok (required during staged enrollment — see Part 4)
  3. Configure sshd_config and run sshd -t
  4. Enroll TOTP for your user before reload if PAM has no nullok
  5. Reload SSH — keep current session open
  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

What this part is for: cover: Install the Google Authenticator PAM module.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

Debian / Ubuntu:

RUN — paste into a shell on the host this section describes.
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:

RUN — paste into a shell on the host this section describes.
sudo dnf install google-authenticator qrencode

Confirm the PAM module exists:

RUN — locate the PAM module path on your distro.
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:

Part 4 — Configure /etc/pam.d/sshd

What this part is for: get a ready template for: 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:

RUN — paste into a shell on the host this section describes.
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:

COPY FILE — add to /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
Lockout trap: Without nullok, reloading SSH before every user enrolls denies login. Remove nullok only after all accounts have ~/.google_authenticator.
RUN — inspect drop-in overrides before reload (Ubuntu/cloud images).
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:

COPY FILE — migration-only line 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 this part is for: get a ready template for: Configure sshd_config.
What you should do: COPY files; edit for your host; RUN apply/restart as labeled.
RUN — paste into a shell on the host this section describes.
sudo nano /etc/ssh/sshd_config

Ensure PAM is enabled:

COPY FILE — add to /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.

COPY FILE — recommended /etc/ssh/sshd_config block (key + TOTP).
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:

COPY FILE — try this form in /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:

RUN — paste into a shell on the host this section describes.
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:

COPY FILE — password + TOTP (weaker than keys; lab only).
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

COPY FILE — hardening lines for /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 this part is for: cover: 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:

RUN — paste into a shell on the host this section describes.
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:

RUN — paste into a shell on the host this section describes.
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:

READ — explanatory content. Follow the paragraph above.
~/.google_authenticator

Lock down permissions — the file contains the TOTP secret:

RUN — paste into a shell on the host this section describes.
chmod 600 ~/.google_authenticator
chown "$USER:$USER" ~/.google_authenticator

Part 7 — Validate config and reload SSH

What this part is for: get a ready template for: 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:

RUN — paste into a shell on the host this section describes.
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:

RUN — paste into a shell on the host this section describes.
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):

RUN — paste into a shell on the host this section describes.
# 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 this part is for: cover: 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.
  1. Keep your current SSH window open
  2. Open a new terminal on your workstation
  3. Connect with verbose logging:
RUN — debug a login attempt (keep a second session open).
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:

  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

What this part is for: cover: 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).

RUN — verify clock sync (TOTP fails if NTP is wrong).
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:

RUN — paste into a shell on the host this section describes.
# 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

Locked out entirely

Verbose client and server logs

RUN — debug a login attempt (keep a second session open).
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.

RUN — follow SSH authentication events in journald.
# 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.

RUN — paste into a shell on the host this section describes.
# 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:

RUN — paste into a shell on the host this section describes.
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 this part is for: cover: 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.

COPY FILE — append to /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

COPY FILE — optional per-group exception in sshd_config.
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

What this part is for: cover: 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.