SSH Two-Factor Authentication (TOTP) on CloudLinux 9 + cPanel
OS: CloudLinux 9
Panel: cPanel/WHM
Method: TOTP viapam_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
qrencodefor 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.