SSH Two-Factor Auth on a VPS: Google Authenticator + PAM
SSH keys stop brute-force bots cold, but they don't help if your laptop gets stolen, your key file leaks in a backup, or someone finds an unencrypted private key sitting in a CI pipeline. If you want a second factor that survives a compromised key, Google Authenticator's PAM module gives you real TOTP codes on top of SSH — and it takes about fifteen minutes to set up correctly.
Symptom: why key-only auth isn't the finish line
Most of the guidance out there stops at "use SSH keys, disable passwords." That's necessary, but it isn't the same as two-factor. A single leaked or stolen private key is still enough to get a shell on your box. You'll want this extra layer if:
- Multiple people SSH into the same VPS and you can't fully control where their key files live.
- You handle client data, payment processing, or anything under a compliance requirement that specifically calls for MFA on server access.
- You've ever copied a private key onto a laptop, a USB drive, or a cloud sync folder "just for now."
None of that means your current setup is broken — it means you're one lost laptop away from a bad week.
Cause: PAM sits between SSH and the login prompt
Linux authenticates SSH sessions through PAM (Pluggable Authentication Modules), a stack of checks SSH calls before it lets a session through. The libpam-google-authenticator package adds a TOTP (time-based one-time password) check to that stack — the same six-digit-code mechanic apps like Authy or Google Authenticator use for websites. Once it's wired in, SSH asks for your key and a code that changes every 30 seconds, generated from a secret only your phone and the server know.
The part people get wrong is the order of operations: enabling the PAM check and flipping ChallengeResponseAuthentication on before testing in a second session. Get that sequence wrong and you can lock yourself out of a box with no console access.
Fix: set it up without locking yourself out
1. Install the PAM module
# Ubuntu/Debian
sudo apt update
sudo apt install libpam-google-authenticator
# AlmaLinux/CentOS/RHEL (EPEL)
sudo dnf install epel-release
sudo dnf install google-authenticator
2. Generate a TOTP secret for your user
Run this as the user who logs in over SSH — not root, unless you actually SSH in as root:
google-authenticator
Answer the prompts:
- Time-based tokens? Yes.
- Scan the QR code (or type the secret manually) into Google Authenticator, Authy, or 1Password on your phone.
- Save the emergency scratch codes somewhere that isn't the server itself — a password manager, not a text file in
/root. If you lose your phone, these are the only way back in. - Disallow multiple uses of the same code? Yes.
- Increase the time window? No, unless your server clock drifts (fix the clock instead — see the note below).
- Enable rate limiting? Yes.
This writes a secret to ~/.google_authenticator. It's per-user, so repeat this step for every account that needs 2FA.
3. Wire PAM into SSH
Edit /etc/pam.d/sshd and add this line near the top (after the existing @include common-auth or similar):
auth required pam_google_authenticator.so nullok
nullok is your safety net for this rollout — it lets users without a configured token still log in with just their key, so you don't lock out accounts you haven't set up yet. Remove it once every account has 2FA configured.
4. Tell sshd to actually ask for the code
Edit /etc/ssh/sshd_config:
KbdInteractiveAuthentication yes
AuthenticationMethods publickey,keyboard-interactive
AuthenticationMethods publickey,keyboard-interactive is what actually enforces both factors — key first, then the TOTP prompt. If you skip this line, PAM's check still runs but SSH may let key-only logins through unchallenged, which defeats the point. On older OpenSSH versions you'll see ChallengeResponseAuthentication yes instead of KbdInteractiveAuthentication — check sshd -V if you're not sure which your version expects, and keep the deprecated directive as a fallback if the newer one is rejected.
5. Restart sshd — but do not close your current session
sudo systemctl restart sshd
Open a second terminal and try logging in fresh. You should be prompted for your key, then a verification code. If it fails, fix it from your still-open first session — that's the whole reason you kept it open.
6. Clean up after everyone's enrolled
Once every SSH user has run google-authenticator and confirmed a successful login, remove nullok from the PAM line so a missing token blocks access instead of silently skipping it.
Prevention: the mistakes that cause lockouts
| Mistake | What happens | Avoid it by |
|---|---|---|
| Restarting sshd before testing in a second session | You're locked out with no way back in except console/VNC access from your VPS provider | Always keep the original session open until a fresh login succeeds |
| Server clock drift | TOTP codes are time-based; more than ~90 seconds of drift makes every code "invalid" | Run timedatectl and confirm NTP sync, or install chrony |
| Losing scratch codes with your phone | No account recovery path without provider console access | Store the 5 scratch codes in a password manager, not on the server |
Forgetting AuthenticationMethods in sshd_config | PAM check exists but isn't actually enforced — key alone still logs you in | Confirm the line is present and sshd was restarted after editing it |
| Applying this to automated/CI deploy keys | Scripted deployments break because there's no human to type a code | Exempt service accounts via PAM's Match User block instead of disabling 2FA globally |
Exempting automation accounts
If a CI pipeline or deploy script needs key-only SSH, don't strip 2FA from everyone — scope it in sshd_config:
Match User deploy-bot
AuthenticationMethods publickey
Place this block at the end of the file. Human accounts keep both factors; the automation account keeps key-only access on a tightly scoped, ideally read-only or single-purpose key.
Verifying it actually worked
From a machine that's never connected before, run ssh -v deploy@your-server-ip and watch the verbose output — you should see the client offer a public key, get accepted, and then the server send a keyboard-interactive prompt for the verification code before the session opens. If you're dropped straight into a shell with no code prompt, AuthenticationMethods isn't taking effect and you're still on key-only auth.
FAQ
Frequently asked questions
Does this replace SSH keys, or work alongside them?
Alongside. This setup requires both a valid SSH key and a TOTP code — that's the point of two-factor. Don't switch back to password authentication as your first factor; keys are still far more resistant to brute-forcing than passwords.
What happens if I lose my phone and my scratch codes?
You'll be locked out of that account over SSH. Recovery means using your VPS provider's browser-based console (VNC/KVM access) to log in locally and either regenerate a new secret with google-authenticator or temporarily add nullok back to /etc/pam.d/sshd.
Will this break SFTP or SCP file transfers?
No, as long as your SFTP/SCP client supports keyboard-interactive prompts — most modern ones do. Automated transfer scripts without a human present will need the Match User exemption covered above.
Can I use this with cPanel's SSH Access Manager instead of a bare VPS?
cPanel accounts don't typically get root-level PAM access on shared or reseller hosting, so this applies to VPS and dedicated servers where you manage sshd_config directly — not standard cPanel shared hosting SSH access.
Why did my codes stop working after months of no issues?
This is almost always clock drift on the server. Run timedatectl status and confirm NTP synchronization is active; if chrony or systemd-timesyncd isn't running, install and enable it.