Securing SSH with Two-Factor Authentication (TOTP)
Protect SSH logins with a key plus a one-time code: set up libpam-google-authenticator, configure PAM and sshd correctly – without locking yourself out.
Table of contents
An SSH key is strong – as long as nobody has your laptop. A second factor closes exactly that gap: whoever logs in needs the key and a six-digit code from an authenticator app. We add it step by step – and put up a safety net from the start so you don’t lock yourself out.
What are we building?
At the end your server requires two independent factors for SSH logins: the private key and a time-based one-time code (TOTP) from an app such as Aegis, FreeOTP or Google Authenticator. The code is checked by PAM, not by SSH itself – which is why two configurations have to line up.
Built and verified on Debian 13 with OpenSSH 10.0p2 and
libpam-google-authenticator 20191231-2.1. We proceed in two stages: first a single test user, then
everyone. And we verify each step before the existing login goes away.
The rule for this article: keep a second session open
Prerequisites
- A Debian 13 server whose SSH access is already hardened: key-based login, no root login with a password
- An authenticator app on your phone. Recommended: Aegis (Android) or FreeOTP – both open and without forced cloud sync
- Access to an emergency console at your provider (VNC in the netcup SCP) in case something does go wrong
- About 40 minutes, unhurried – this is not a step for „in between"
VPS 1000 G12.5
4 vCores · 8 GB RAM · 128 GB SSD
from €14.50/month
Runs on any server – the VNC console in the SCP is the real safety net here.
💶 1 month free:
Single use, valid for VPS 1000 G12.5. Redeem it in an empty cart – the code adds the server itself. Redeem in the cart →
💶 €5 voucher for new netcup customers: always valid · not for domains or VPS Lite
Step by step
1. Install the PAM module
The TOTP code is checked by a PAM module, not by the SSH server:
sudo apt update && sudo apt install libpam-google-authenticatorThe name is misleading: the module speaks the open TOTP standard (RFC 6238) and needs neither a Google account nor internet access. Any standard authenticator app works.
Also handy is pamtester – it lets you verify the login without having to open a new SSH session:
sudo apt install pamtester2. Create a secret for one user
Log in as the user who should get 2FA (not as root – the secret belongs in their home directory) and run:
google-authenticator -t -d -f -r 3 -R 30 -WThe flags are deliberate and save you five interactive questions. One remains, and that is a good thing – more on it in a moment:
-ttime-based codes (TOTP) instead of counter-based ones-dforbids reusing a code – against shoulder surfing-fwrites the file without first asking whether it should-r 3 -R 30rate limiting: at most 3 attempts per 30 seconds-Wa tighter time window (less tolerance, but safer)
The tool shows a QR code and the secret in plain text. Scan the QR code with your app – then it
asks Enter code from app (-1 to skip):. Type in the code the app shows right now: it’s the first
check that app and server compute the same thing, and you shouldn’t skip it. Only then does the
tool write the file and show the emergency scratch codes. Write them down on paper – they
are your way back if the phone is lost.
That prompt is also why the command can’t run unattended in a script: without a terminal it aborts
with getline(): Inappropriate ioctl for device and creates no file.
Afterwards the configuration sits in the home directory:
ls -l ~/.google_authenticator && grep '^"' ~/.google_authenticator-r-------- 1 koch koch 135 Jul 25 09:14 /home/koch/.google_authenticator
" RATE_LIMIT 3 30
" WINDOW_SIZE 3
" DISALLOW_REUSE
" TOTP_AUTHPermissions 0400, owned by the user themselves – that is how it should be. Whoever can read this
file can generate codes.
3. Configure PAM – with nullok as the safety net
Open /etc/pam.d/sshd and put this as the first auth line:
auth required pam_google_authenticator.so nullokThat nullok is the most important part of this article: it means „users without
~/.google_authenticator may still get in without a code". So you lock nobody out while rolling 2FA
out – and you stay in yourself as long as you have not created a secret.
nullok has to go again at the end
nullok is a ramp, not a state: as long as it is there, any user can bypass the second factor simply
by not creating a secret. Once all accounts are set up, remove the word – but not before, and
verify first with pamtester (step 6) that the login works for every user.4. sshd: allow keyboard-interactive authentication
SSH asks for the code through the „keyboard-interactive" mechanism, and on Debian 13 that is off by default. Instead of editing the main file, add a drop-in – that way it survives package updates untouched:
echo "KbdInteractiveAuthentication yes" | sudo tee /etc/ssh/sshd_config.d/60-totp.confCheck the configuration before reloading – a syntax error would otherwise cost you your access:
sudo sshd -t && sudo systemctl reload sshConfirm what is actually in effect now:
sudo sshd -T | grep -E "kbdinteractive|usepam"usepam yes
kbdinteractiveauthentication yes5. Enforce two factors – for one user first
Now the actual switch: AuthenticationMethods demands several methods in sequence. So that a
mistake doesn’t hit every account, the rule initially applies to one test user only:
# /etc/ssh/sshd_config.d/61-totp-user.conf
Match User YOUR_TEST_USER
AuthenticationMethods publickey,keyboard-interactivesudo sshd -t && sudo systemctl reload sshWhat applies to whom is revealed by sshd -T with a user context – a command that saves a lot of
guesswork:
sudo sshd -T -C user=YOUR_TEST_USER,host=localhost,addr=127.0.0.1 | grep authenticationmethods
sudo sshd -T -C user=root,host=localhost,addr=127.0.0.1 | grep authenticationmethodsauthenticationmethods publickey,keyboard-interactive
authenticationmethods anyExactly what we wanted: the test user needs both, everyone else is untouched.
6. Verify without locking yourself out
Before you risk a new SSH session, test the PAM stack directly. pamtester asks exactly what SSH
would ask:
sudo pamtester -v sshd YOUR_TEST_USER authenticatepamtester: invoking pam_start(sshd, koch2, ...)
pamtester: performing operation - authenticate
Verification code: Password: pamtester: successfully authenticatedTwo observations that matter in a moment: the code is asked for first, then the password – the order follows from the position in the PAM stack. And with a wrong code the same command answers:
Verification code: Password: pamtester: Authentication failureOnly once that works do you open a new session (leaving the old one open!) and log in as the test user. The sequence: the key is accepted automatically, then the server asks for the code.
7. The stumbling block: code and password?
In the default configuration the login demands both after the key – the one-time code and the
password. That is not SSH’s fault but a consequence of UsePAM yes: keyboard-interactive runs the
entire PAM stack, first your TOTP module and then, via @include common-auth, the password check
as well.
The test was unambiguous: key + correct code + correct password → login successful; key + correct code
- wrong password →
Permission denied (keyboard-interactive).
If you want key + code without a password (the more common variant, since the password adds little
next to the key), comment out the password check in /etc/pam.d/sshd:
auth required pam_google_authenticator.so nullok
#@include common-authAfter that, key + code is enough – confirmed in the test, and a wrong code is still rejected.
Without common-auth there is no password login – for anyone
8. Roll it out to all users
Once the test user works cleanly and every account has its secret, make the rule general: replace the
Match block with a global line and drop nullok.
# /etc/ssh/sshd_config.d/60-totp.conf
KbdInteractiveAuthentication yes
AuthenticationMethods publickey,keyboard-interactive# /etc/pam.d/sshd – without nullok, so nobody can bypass the factor
auth required pam_google_authenticator.soAnd again: sudo sshd -t, reload, verify with pamtester, then test a new session – with the old
one still open.
Exempt automation
Backup jobs, CI runners or deployments cannot type a code. Exempt them deliberately instead of dropping 2FA altogether:
Match User deploy
AuthenticationMethods publickeySuch an account should then get its own restricted key (see the command= option in
authorized_keys) – not the same rights as your personal account.
When things go wrong
No code prompt appears at all, the login works as before. Then KbdInteractiveAuthentication is
still no – the default on Debian 13. Check what really applies with
sudo sshd -T | grep kbdinteractive, and note that files in /etc/ssh/sshd_config.d/ are read in
alphabetical order and the first setting wins.
After the key you are also asked for the password. Not a bug but the consequence of UsePAM yes:
keyboard-interactive runs the whole PAM stack, including @include common-auth. If you only want key
- code, comment that line out – with the consequence from the warning in step 7.
The code is rejected even though the app shows it right now. Usually the server clock has drifted.
TOTP tolerates only a few seconds. Check timedatectl – it must say System clock synchronized: yes;
otherwise enable NTP. Second possibility: you used the same code shortly before and DISALLOW_REUSE
forbids that. This also applies if the previous attempt only failed at the password – the code
was correct and is therefore used up. In our test the same code was rejected right afterwards with
the correct password; the next one went through. So simply wait for the next one.
After three failed attempts nothing works any more. That is the rate limit from -r 3 -R 30: three
attempts per 30 seconds. Every round counts: after a wrong password the SSH client asks again up to
three times, and that alone exhausts the limit. Wait a moment instead of typing on.
One user gets in without a code. Either their ~/.google_authenticator is missing and nullok
lets them pass, or a Match block with different AuthenticationMethods applies to them.
sudo sshd -T -C user=NAME,host=localhost,addr=127.0.0.1 | grep authenticationmethods shows what really
applies to them.
Phone lost, no scratch codes. Then only an access path that does not go through SSH helps: the
provider’s VNC console. Log in there and delete the user’s file (rm ~/.google_authenticator) – with
nullok they get back in without a code, without it you end up with an account that has no second
factor and needs to be set up again.
Your problem is not listed? Search all error messages →
Maintenance & backups
Scratch codes belong in a backup – but not on the server. The five codes from step 2 are equivalent to the second factor. Stored on the same server they would be pointless; in your password manager vault or on paper in a drawer they belong.
Back up the secrets – or deliberately don’t. ~/.google_authenticator is part of the home
directory. If you back up home directories with Restic, the second
factor ends up in the backup: convenient when restoring, but an attacker with backup access then has
both factors. For most self-hosters that is an acceptable trade-off – you just want to have decided it
consciously rather than overlooked it.
Re-check after every OpenSSH update. Options get renamed (ChallengeResponseAuthentication became
KbdInteractiveAuthentication), and old names disappear eventually. A
sudo sshd -T | grep -E "kbdinteractive|authenticationmethods" after the update shows in two seconds
whether your hardening still applies or has silently become ineffective. How to automate updates is
covered in
automatic updates.
What 2FA does not do. The second factor protects the login. It does not help against a vulnerable application behind your reverse proxy, a leaked Docker socket mount or an unpatched kernel. It is one layer next to a firewall, fail2ban and CrowdSec – and it replaces none of them.
Send feedback: feedback@serverkueche.de
You might also like


