How to add a second step to SSH logins with an authenticator app

An SSH key is a strong lock, but keys get copied off laptops and out of old backups. With a second step, a stolen key alone is not enough: SSH also asks for a six-digit code from the app on your phone.

Keep a second SSH session open the whole time. A mistake in these files can stop every login, including yours. Only close the old session after a new login works (step 6).

Before you start

  • You log in with a key, and password login is off. If not, do our SSH keys guide first. This guide changes the file it made, /etc/ssh/sshd_config.d/00-keys-only.conf.
  • You log in as a normal user with sudo, not as root. See how to make a sudo user. Our example user is alice.
  • You have an authenticator app on your phone, for example Google Authenticator, Microsoft Authenticator, Aegis or 2FAS. Any app that does six-digit codes ("TOTP") works.
  • You know how to reach your hosting provider's web console (sometimes called VNC, rescue or serial console). It works without SSH, so it's your way back in if something goes wrong.

We tested every step on Ubuntu 24.04 and Debian 13.

1. Check the server's clock

The codes change every 30 seconds, and the server works them out from its own clock. If the clock is a few minutes off, every code is wrong. Check it:

timedatectl

You want System clock synchronized: yes and NTP service: active. If not, turn on time sync:

sudo timedatectl set-ntp true

If that says "NTP not supported", no time service is installed. Install one with sudo apt install systemd-timesyncd and run the command again.

Your phone's clock matters too. Phones normally set it on their own.

2. Install the code checker

sudo apt install libpam-google-authenticator

Despite the name, this has nothing to do with a Google account. It's a small open-source module that checks codes on your server. Nothing is sent anywhere.

3. Set up your code

Run this as yourself, not with sudo. Each user who logs in with SSH runs it for their own account:

google-authenticator -t -d -f -r 3 -R 30 -w 3

What the options mean:

  • -t: codes based on the time, the kind authenticator apps use.
  • -d: each code works only once. Someone who sees you type it can't use it again.
  • -r 3 -R 30: at most 3 login tries every 30 seconds.
  • -w 3: also accept the code from just before and just after the current one, so a slow typist or a clock a little off still gets in.
  • -f: save the settings without asking.

It prints a QR code (make the terminal window big enough) and a secret key. Scan the QR code with your app, or type the secret key into it. Then type the code the app shows. When it's right, the command saves everything in ~/.google_authenticator, readable only by you.

It also prints five emergency scratch codes, eight digits each. Each one works once, in place of a code from the app, for the day your phone is lost or broken. Save them in your password manager now, not on the server. Treat the secret key the same way: anyone who has it can make your codes.

4. Tell SSH to ask for the code

First save a copy of the file you are about to change:

sudo cp /etc/pam.d/sshd /root/sshd.pam.backup
sudo nano /etc/pam.d/sshd

Near the top, find @include common-auth. Put a # in front of it and add one line below:

# @include common-auth
auth required pam_google_authenticator.so

Why turn off common-auth? It asks for your Linux password. If you leave it on, SSH asks for your password and the code, even though you already used a key. This change is for SSH only. sudo still asks for your password as before.

Now open the SSH settings file from the keys guide:

sudo nano /etc/ssh/sshd_config.d/00-keys-only.conf

Change KbdInteractiveAuthentication no to yes, and add the AuthenticationMethods line:

PasswordAuthentication no
KbdInteractiveAuthentication yes
AuthenticationMethods publickey,keyboard-interactive
PermitRootLogin no
  • KbdInteractiveAuthentication yes lets SSH ask questions, here "Verification code". Older guides call it ChallengeResponseAuthentication. That's the old name, so use the new one.
  • AuthenticationMethods publickey,keyboard-interactive means: first the key, then the code. Both are needed. The comma means "and".

Change the existing file rather than making a new one. SSH uses the first value it finds for each setting, so a no in an earlier file would win.

5. Make exceptions before you switch it on

From now on, every SSH user needs a code. In our tests, a user without ~/.google_authenticator was refused, even with the right key. The nullok option that some guides add didn't change that, because common-auth is off. So either set up a code for each person (step 3), or make an exception.

Deploy and backup users run from scripts that can't type a code. Let them in with their key only:

sudo nano /etc/ssh/sshd_config.d/10-2fa-exceptions.conf
Match User deploy
    AuthenticationMethods publickey

Several users: Match User deploy,backup. Keep these users' keys narrow: one key per job, and no sudo rights unless they need them.

SFTP-only users from our SFTP guide are refused too, unless you add the same line to the end of their Match block:

Match User anna
    ChrootDirectory /srv/sftp/shop
    ForceCommand internal-sftp -u 0002 -d /site
    ...
    AuthenticationMethods publickey

6. Check, apply and test

sudo sshd -t
sudo sshd -T -C user=alice,host=x,addr=203.0.113.5 | grep -Ei 'kbdinteractive|authenticationmethods'
sudo sshd -T -C user=deploy,host=x,addr=203.0.113.5 | grep -i authenticationmethods

sshd -t prints nothing when there are no mistakes. For alice you should see kbdinteractiveauthentication yes and authenticationmethods publickey,keyboard-interactive. For deploy, just publickey. Then:

sudo systemctl restart ssh

Your open session stays connected. In a new window, log in:

ssh alice@your-server
(alice@your-server) Verification code:

Type the code from the app. Here is what we checked with real codes on both systems. Do the same:

  • Key and the right code: you're in.
  • Key and a wrong code: Permission denied (keyboard-interactive).
  • The same code again within the same 30 seconds: refused, because each code works once.
  • A right code but no key (ssh -o PubkeyAuthentication=no alice@your-server): Permission denied (publickey). SSH doesn't even ask for a code.
  • A scratch code: works once. The second time it's refused.
  • deploy with its key: in, with no question. anna with SFTP: in, as before.
  • A fourth try within 30 seconds: refused even with the right code. Wait half a minute.

Only when your own login works should you close the old session.

Good to know

  • Every new connection asks for a code, including scp, rsync and your editor's remote plugin. Tools that can't show a question will fail. Give them their own user with an exception, as in step 5.
  • Lost your phone? Log in with a scratch code, then run step 3 again to make a new secret, and scan it with the new phone. This also makes the old scratch codes useless.
  • Down to the last scratch code? Run step 3 again to get new ones.
  • Someone leaves? Remove their key from authorized_keys and delete their ~/.google_authenticator.
  • Codes suddenly stop working for everyone: check the clock (step 1). In our test, a code from three minutes ago was refused.

Another way: a security key

If you have a hardware security key that supports FIDO2 (a YubiKey, for example), SSH can use it directly. The key file on your laptop is then useless without the device plugged in, and you touch it to log in. Per the ssh-keygen manual, you make such a key with:

ssh-keygen -t ed25519-sk

You copy it to the server like any other key, and no server changes are needed: on both Ubuntu 24.04 and Debian 13, SSH accepts these keys out of the box. We didn't have a device in our test, so we couldn't try a login with one. Buy two and register both, so losing one doesn't lock you out.

If you get locked out

Log in with your hosting provider's web console. Then undo the change to PAM and turn the code step off:

sudo cp /root/sshd.pam.backup /etc/pam.d/sshd
sudo nano /etc/ssh/sshd_config.d/00-keys-only.conf

Set KbdInteractiveAuthentication no, delete the AuthenticationMethods line, then run sudo sshd -t && sudo systemctl restart ssh. Your key works on its own again. Find what went wrong (often the clock, or a user without a code), and try again.

If something goes wrong

  • SSH asks for a password before the code: @include common-auth is still on in /etc/pam.d/sshd.
  • One user is always refused, even with the right key: that user has no ~/.google_authenticator, so no code can work. Set one up (step 3) or add an exception (step 5).
  • Every code is refused: check the clock on the server and on the phone. Also check that you scanned the newest QR code: each run of step 3 makes a new secret.
  • No code question at all, and the key works on its own: another file in /etc/ssh/sshd_config.d/ sets KbdInteractiveAuthentication or AuthenticationMethods first. The sshd -T check in step 6 shows what SSH really uses.
  • Details of why a login failed are in sudo journalctl -u ssh (or /var/log/auth.log, if your server has it). Look for lines with pam_google_authenticator.

Official sources

Next steps: block repeated login tries with Fail2ban, and work through the rest of our security checklist for a new PHP server.

ServerNest is in development. Its agent is being built to connect out to ServerNest, so managing your server won't need an extra SSH login or an open port. Join early access to follow along.

← Back to the security blog