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.
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 isalice. - 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 yeslets SSH ask questions, here "Verification code". Older guides call itChallengeResponseAuthentication. That's the old name, so use the new one.AuthenticationMethods publickey,keyboard-interactivemeans: 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.
deploywith its key: in, with no question.annawith 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,rsyncand 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_keysand 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-authis 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/setsKbdInteractiveAuthenticationorAuthenticationMethodsfirst. Thesshd -Tcheck 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 withpam_google_authenticator.
Official sources
- sshd_config manual (OpenSSH):
AuthenticationMethods,KbdInteractiveAuthentication,Match - google-authenticator-libpam (GitHub): the code checker and its options
- ssh-keygen manual (OpenSSH): security keys (
ed25519-sk) - The package: Ubuntu 24.04 and Debian 13
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.