SSH Two-Factor Authentication (TOTP) on CloudLinux 9 + cPanel

Share
SSH Two-Factor Authentication (TOTP) on CloudLinux 9 + cPanel
OS: CloudLinux 9
Panel: cPanel/WHM
Method: TOTP via pam_google_authenticator
Supports: Key + TOTP and Password + TOTP simultaneously

Table of Contents

  • Prerequisites
  • Installation
  • PAM Configuration
  • SSH Daemon Configuration
  • User Enrollment
  • Sharing a Secret (Shared Root Setup)
  • Authentication Flow Reference
  • Offboarding a Team Member
  • Troubleshooting
  • Reverting Everything

Prerequisites

  • Root access via an active SSH session
  • EPEL repository available
  • qrencode for QR image generation (optional)

Always keep your current SSH session open while making changes. Test in a second terminal before closing.


Installation

dnf install -y epel-release
dnf install -y google-authenticator qrencode

PAM Configuration

Edit /etc/pam.d/sshd:

# ADD this at the very top (before any auth lines)
auth       required     pam_google_authenticator.so nullok

# COMMENT OUT the password-auth substack
#auth      substack     password-auth

# Leave the rest untouched

Why comment out password-auth:
Without commenting it out, PAM prompts for both the Unix password and TOTP on top of key/password auth — triple prompting. Commenting it out leaves only TOTP in the PAM stack. The Unix password is handled by SSH's own password auth method (reads /etc/shadow directly), completely separate from PAM keyboard-interactive.

nullok allows users without a TOTP secret configured to still log in. Remove it once all users are enrolled.


SSH Daemon Configuration

cPanel manages /etc/ssh/sshd_config and can overwrite it on updates. Always use the drop-in directory instead.

mkdir -p /etc/ssh/sshd_config.d
cat > /etc/ssh/sshd_config.d/2fa.conf <<'EOF'
PasswordAuthentication yes
PubkeyAuthentication yes
ChallengeResponseAuthentication yes
KbdInteractiveAuthentication yes
AuthenticationMethods publickey,keyboard-interactive password,keyboard-interactive
UsePAM yes
EOF

AuthenticationMethods explained:

Value Meaning
publickey,keyboard-interactive Key auth first, then TOTP
password,keyboard-interactive Unix password first, then TOTP

Both alternatives are listed space-separated, so either path satisfies authentication.

CloudLinux 9 ships OpenSSH 8.7+ where ChallengeResponseAuthentication is deprecated in favor of KbdInteractiveAuthentication. Include both for compatibility.

Validate and reload:

sshd -t && systemctl reload sshd

User Enrollment

Run as the user being enrolled (switch to them first if needed):

google-authenticator -t -d -f -r 3 -R 30 -w 3
Flag Meaning
-t Time-based TOTP
-d Disallow token reuse
-f Write to ~/.google_authenticator without prompting
-r 3 -R 30 Rate limit: max 3 attempts per 30 seconds
-w 3 Window size: accept ±3 codes (handles clock drift)

The command outputs:

  • A QR code (scan with authenticator app)
  • The raw secret key (for manual entry)
  • Emergency scratch codes (save these securely)

Secret is stored at ~/.google_authenticator (for root: /root/.google_authenticator).


Sharing a Secret (Shared Root Setup)

Use when multiple admins share the root account. Individual accountability is maintained via separate SSH keys per person (fingerprint is logged in /var/log/secure).

Display QR in Terminal

google-authenticator --qr-mode=UTF8 < /root/.google_authenticator

Generate a PNG QR Code

SECRET=$(head -1 /root/.google_authenticator)
HOSTNAME=$(hostname -f)

qrencode -o /root/totp-qr.png \
  "otpauth://totp/root@${HOSTNAME}?secret=${SECRET}&issuer=${HOSTNAME}"

Transfer to local machine:

scp root@yourserver:/root/totp-qr.png ~/Desktop/

Delete the PNG from the server immediately after transfer.

shred -u /root/totp-qr.png 2>/dev/null || true

Get Manual Entry Details

echo "Secret : $(head -1 /root/.google_authenticator)"
echo "Account: root@$(hostname -f)"
echo "Type   : TOTP"
echo "Digits : 6"
echo "Period : 30s"
echo "Algo   : SHA1"

Teammate Instructions

Field Value
Account root@yourserver.com
Secret (from command above)
Type Time-based (TOTP)
Digits 6
Period 30 seconds

Compatible apps: Google Authenticator · Authy · 1Password · Bitwarden

To add manually: Add account → Enter key manually → fill in the table above.


Authentication Flow Reference

User type Step 1 Step 2 Result
Key user pubkey ✓ TOTP ✓ shell
Password user Unix password ✓ TOTP ✓ shell

SSH key fingerprint is always logged regardless of auth method:

grep 'Accepted publickey' /var/log/secure | tail -20

Offboarding a Team Member

# 1. Remove their SSH key
vi /root/.ssh/authorized_keys
# Delete their public key line

# 2. Regenerate the TOTP secret
google-authenticator -t -d -f -r 3 -R 30 -w 3

# 3. Re-share the new secret with remaining teammates
# (repeat Sharing a Secret section above)

Both steps are required. Revoking the key alone still leaves the old TOTP secret usable by anyone who saved it in their app.


Troubleshooting

SELinux Denials

ausearch -m avc -ts recent | grep google

# If denials found:
setsebool -P authlogin_yubikey 1

# Or generate a local policy from denials:
ausearch -m avc -ts recent | audit2allow -M google_auth
semodule -i google_auth.pp

CSF / Login Failure Tracking

grep LF_SSHD /etc/csf/csf.conf
# Verify the value is sane (e.g. 5)
# CSF counts auth failures, not PAM prompts — should be fine as-is

sshd_config Overwritten by cPanel

cPanel updates can touch /etc/ssh/sshd_config. The drop-in at /etc/ssh/sshd_config.d/2fa.conf survives this. Verify after WHM updates:

sshd -T | grep -E 'authmethods|usepam|kbdinteractive'

Double Password Prompt

Caused by auth substack password-auth still active in /etc/pam.d/sshd. Confirm it is commented out:

grep 'password-auth' /etc/pam.d/sshd
# Should show: #auth substack password-auth

User Can Log In Without TOTP

nullok is still set in /etc/pam.d/sshd. Remove it once all users have enrolled:

sed -i 's/pam_google_authenticator.so nullok/pam_google_authenticator.so/' /etc/pam.d/sshd
sshd -t && systemctl reload sshd

Reverting Everything

# 1. Remove the sshd drop-in
rm -f /etc/ssh/sshd_config.d/2fa.conf

# 2. Restore /etc/pam.d/sshd
#    - Remove the pam_google_authenticator line
#    - Uncomment auth substack password-auth
vi /etc/pam.d/sshd

# 3. Reload sshd
sshd -t && systemctl reload sshd

# 4. Remove packages and secret (optional)
dnf remove -y google-authenticator
rm -f /root/.google_authenticator

Verify password login works from a second terminal before closing your current session.