Before you start
Stay logged in to the server in one window until the end. Check that you have a console from your hosting provider as well. Set up key login first: see how to generate a key.
Step 1: install the module
sudo apt update
sudo apt install libpam-google-authenticator
Despite the name, it needs no Google account and works with any TOTP app. The server checks the codes itself.
Step 2: create a secret for each user
Run this as the user who will log in, not with sudo:
google-authenticator -t -d -f -r 3 -R 30 -w 3
| Flag | Meaning |
|---|---|
-t | Time-based codes |
-d | A code cannot be used twice. We checked: a second login with the same code failed. |
-f | Write the file without asking |
-r 3 -R 30 | At most 3 logins every 30 seconds. Remember this when you test: quick repeated attempts fail even with a correct code. |
-w 3 | Accept the current code and the ones just before and after, to allow for a small clock difference |
Scan the QR code with your app and type a code to confirm. If you cannot scan it, type the secret key into the app by hand. The tool also prints a link to a Google chart page and warns that opening it exposes the secret to Google, so do not open it. The command prints five emergency scratch codes. Save them somewhere other than this server and your phone. Each one works once in place of a code.
This creates ~/.google_authenticator with mode 400. Keep it that way: in our test, with mode 644 logins failed even with a correct code.
Step 3: configure PAM
Edit /etc/pam.d/sshd. Comment out the line that asks for the password, and add two lines under it:
# @include common-auth
auth required pam_google_authenticator.so nullok
auth required pam_permit.so
- Why comment out
common-auth: if you leave it, our test login asked for the code and then the system password too. - What
nullokdoes: a user with no secret file can log in without a code, so you can roll this out one user at a time. Remove it when everyone is set up. - Why
pam_permit.so:nullokmakes the module abstain, and PAM needs at least one module to succeed. Without this line, users with no secret file were refused in our test. Users who do have a file still need the right code.
Once you remove nullok, you can remove pam_permit.so too. Red Hat systems use a different first line, and the package comes from EPEL. Check your distribution's documentation.
Step 4: configure sshd
# /etc/ssh/sshd_config.d/10-2fa.conf
AuthenticationMethods publickey,keyboard-interactive
KbdInteractiveAuthentication yes
UsePAM yes
The comma means both are required, in that order. Use a file in this folder because the first value read wins, and Debian's main sshd_config has KbdInteractiveAuthentication no further down. Older systems called this setting ChallengeResponseAuthentication.
Check the file before you reload:
sudo sshd -t
sudo sshd -T | grep -iE '^(authenticationmethods|kbdinteractiveauthentication|usepam) '
sudo systemctl reload ssh
If KbdInteractiveAuthentication is off, sshd -t tells you, and you should not reload:
Disabled method "keyboard-interactive" in AuthenticationMethods list "publickey,keyboard-interactive"
AuthenticationMethods cannot be satisfied by enabled authentication methods
Step 5: test from a new window
ssh user@server
(user@server) Verification code:
What we got in each case:
| Attempt | Result |
|---|---|
| Key and correct code | Logged in. The log says Accepted keyboard-interactive/pam for user. |
| Key and wrong, reused or five-minute-old code | Permission denied (keyboard-interactive). |
| No key | Permission denied (publickey). No code is asked for, and no password is accepted. |
| Key and scratch code | Logged in once. The code is then removed from the file and fails if used again. |
If correct codes fail, check the clock first. The server and the phone must agree on the time. Run timedatectl on the server and see if the clock is synchronized.
Keep a way back in
Have at least two of these before you close your last open session:
- Your scratch codes, stored away from the phone. If the phone is lost, use one to get in, then run
google-authenticatoragain to replace the secret. - The provider's console. It does not use SSH. From there, delete
10-2fa.confand reload. - An exception by address, for a network only you can reach, such as a VPN:
# At the end of 10-2fa.conf
Match Address 10.8.0.0/24
AuthenticationMethods publickey
- A separate emergency account, with a key kept offline. Add the same block with
Match User breakglass. This also fits an automated job, if you limit its key.
Check an exception with -C, because plain sshd -T does not show Match blocks:
sudo sshd -T -C user=user,host=server,addr=10.8.0.5 | grep -i authenticationmethods
Every exception is a way around the second factor, so keep them few and narrow.
To limit what an emergency or job key can run, see how to restrict an SSH key to one command.
Typing fewer codes
With connection sharing, new sessions reuse an open connection and do not ask for a code again.
Questions
Will this affect scp, sftp, rsync and scripts?
Yes. They all log in the same way and will ask for a code, so a job with no one to type it fails. Exempt its account with a Match block, and limit what its key can do with the options in the guide on restricting keys.
Does it protect sudo?
No. This changes only /etc/pam.d/sshd. sudo has its own PAM file and is not affected.