Skip to content
Serverküche
Search

Loading search … (only available on the published site).

Security Difficulty: Intermediate

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.

· 10 min read ·Duration: approx. 40 minutes
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

Everything changed here can lock you out of your server. So keep a second SSH session open for the entire setup, one you are already logged into – an existing connection survives even if you break the login configuration. That lets you undo any change. Anyone who additionally has a console at their provider (with netcup the VNC console in the SCP) is on the safe side.

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"
🍳 Recommendation Ad

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.

Go to netcup →

💶 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

All netcup vouchers →

Step by step

1. Install the PAM module

The TOTP code is checked by a PAM module, not by the SSH server:

Terminal
sudo apt update && sudo apt install libpam-google-authenticator

The 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:

Terminal
sudo apt install pamtester

2. 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:

Terminal
google-authenticator -t -d -f -r 3 -R 30 -W

The flags are deliberate and save you five interactive questions. One remains, and that is a good thing – more on it in a moment:

  • -t time-based codes (TOTP) instead of counter-based ones
  • -d forbids reusing a code – against shoulder surfing
  • -f writes the file without first asking whether it should
  • -r 3 -R 30 rate limiting: at most 3 attempts per 30 seconds
  • -W a 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:

Terminal
ls -l ~/.google_authenticator && grep '^"' ~/.google_authenticator
Ausgabe
-r-------- 1 koch koch 135 Jul 25 09:14 /home/koch/.google_authenticator
" RATE_LIMIT 3 30
" WINDOW_SIZE 3
" DISALLOW_REUSE
" TOTP_AUTH

Permissions 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:

INI
auth required pam_google_authenticator.so nullok

That 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:

Terminal
echo "KbdInteractiveAuthentication yes" | sudo tee /etc/ssh/sshd_config.d/60-totp.conf

Check the configuration before reloading – a syntax error would otherwise cost you your access:

Terminal
sudo sshd -t && sudo systemctl reload ssh

Confirm what is actually in effect now:

Terminal
sudo sshd -T | grep -E "kbdinteractive|usepam"
Ausgabe
usepam yes
kbdinteractiveauthentication yes

5. 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:

INI
# /etc/ssh/sshd_config.d/61-totp-user.conf
Match User YOUR_TEST_USER
    AuthenticationMethods publickey,keyboard-interactive
Terminal
sudo sshd -t && sudo systemctl reload ssh

What applies to whom is revealed by sshd -T with a user context – a command that saves a lot of guesswork:

Terminal
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 authenticationmethods
Ausgabe
authenticationmethods publickey,keyboard-interactive
authenticationmethods any

Exactly 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:

Terminal
sudo pamtester -v sshd YOUR_TEST_USER authenticate
Ausgabe
pamtester: invoking pam_start(sshd, koch2, ...)
pamtester: performing operation - authenticate
Verification code: Password: pamtester: successfully authenticated

Two 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:

Ausgabe
Verification code: Password: pamtester: Authentication failure

Only 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:

INI
auth required pam_google_authenticator.so nullok
#@include common-auth

After 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

This single line affects all SSH logins. Verified: after commenting it out, even a login with the correct password was rejected. Anyone who logs in with a password somewhere – a colleague, a deployment script, your phone client – will be locked out from that moment on. Only take this step once all access paths use keys, and with a second session open.

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.

INI
# /etc/ssh/sshd_config.d/60-totp.conf
KbdInteractiveAuthentication yes
AuthenticationMethods publickey,keyboard-interactive
INI
# /etc/pam.d/sshd – without nullok, so nobody can bypass the factor
auth required pam_google_authenticator.so

And 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:

INI
Match User deploy
    AuthenticationMethods publickey

Such 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.

You might also like