Multi-factor authentication is a common tool to harden sensitive logins on websites. The very same mechanism can also be integrated into authentication on linux devices, from personal machines to productions servers.
Preamble
We assume a debian 13 machine in this guide for simplicity. While most commands may be similar, some portions like names of packages, files and groups may differ.
Time-based One-Time Passwords (TOTP) is the only 2-factor mechanism discussed in this guide. Linux does support others like HOTP or hardware keys, but those are beyond the scope of this guide.
Setup
The first step is to install required packages:
sudo apt update
sudo apt install libpam-oath oathtool basez qrencode pamtesterSince TOTP relies on the current time to generate codes, a correct time configuration is required for it to work. You should enable automatic time syncing over NTP to ensure date and time settings do not drift over time.
If you have not set it up yet, installing the ntp package is enough on debian 13:
sudo apt install systemd-timesyncd
sudo systemctl enable --now systemd-timesyncd
sudo timedatectl set-ntp trueRunning timedatectl status should now show NTP service: active in its output.
Generating a TOTP secret
A TOTP key is simply 20 random bytes encoded in base16 (hexadecimal). Simply grab and encode some random bytes:
head -c 20 /dev/urandom | xxd -pThe output might look like:
d73605335125ef9e4e60Now we need to create a database that contains the TOTP keys for each user:
sudo touch /etc/users.oath
sudo chmod 600 /etc/users.oath
sudo chown root:root /etc/users.oathThe file will maps TOTP keys to users, one per line. Be careful that TOTP authentication will only work for users named in this file, there is no way to apply a TOTP key to all users on the system.
HOTP/T30/6 myuser - d73605335125ef9e4e60The line configures the key we generated earlier for the linux user myuser, using a time window of 30 seconds (T30) and 6 digit codes. The time window and digit count can stay as they are. Don't be confused by the HOTP keyword - this configuration is correct for TOTP, the config file simply treats TOTP as "HOTP with a static counter (-)".
Adjust the configuration as needed and save it to /etc/users.oath.
Configuring PAM
This step has the potential to lock you out of all users on the machine, so be careful when following along.
The Pluggable Authentication Module (PAM) service handles authentication across many different applications on the machine, from console to terminal logins and sudo. In order to add TOTP verification, you should to alter the configuration for all applications you want to target.
You might be tempted to alter a shared config snippet like common-auth, but doing that may interfere with system services like polkit and render the machine unusable.
Instead, configure TOTP for each login type separately, editing the files for login (tty auth), sudo, sshd etc as required.
In order to safely adjust the configuration, we will go through an example by enabling TOTP for sudo by copying /etc/pam.d/sudo to a test file:
sudo cp /etc/pam.d/sudo /etc/pam.d/sudo_testThe contents will look similar to this:
#%PAM-1.0
# Set up user limits from /etc/security/limits.conf.
session required pam_limits.so
@include common-auth
@include common-account
@include common-session-noninteractiveNow edit /etc/pam.d/sudo_test (NOT /etc/pam.d/sudo!!).
To enforce TOTP with our database file, add this line either directly above or below @include common-auth (depending on whether you want to ask for the OTP code before or after the password):
auth required pam_oath.so usersfile=/etc/users.oath window=5 digits=6Leave all other lines untouched.
Test the configuration with pamtester:
sudo pamtester sudo_test myuser authenticateYou should be prompted for the password of myuser and an OTP key, which you can generate with:
oathtool --totp d73605335125ef9e4e60Replace the sample key with the real one generated earlier. If everything worked as expected, replace the old config file:
sudo mv /etc/pam.d/sudo_test /etc/pam.d/sudoTOTP verification is now enforced for sudo operations, and effectively all users not named in /etc/users.oath cannot use the command at all anymore. Repeat the same process for all other login targets you want to secure with TOTP.
Debugging PAM
In case the pamtester command from the previous step failed, you can enable debugging for the PAM module in question.
For the previous sudo_test example file, simply add the word debug to the end of the totp config line:
auth required pam_oath.so usersfile=/etc/users.oath window=5 digits=6 debugSave the file and re-test with pamtester:
sudo pamtester sudo_test myuser authenticateYou will see a lot more output this time:
[../../pam_oath/pam_oath.c:parse_cfg(123)] called.
[../../pam_oath/pam_oath.c:parse_cfg(124)] flags 32768 argc 4
[../../pam_oath/pam_oath.c:parse_cfg(126)] argv[0]=usersfile=/etc/users.oath
[../../pam_oath/pam_oath.c:parse_cfg(126)] argv[1]=window=30
[../../pam_oath/pam_oath.c:parse_cfg(126)] argv[2]=digits=6
[../../pam_oath/pam_oath.c:parse_cfg(126)] argv[3]=debug
[../../pam_oath/pam_oath.c:parse_cfg(127)] debug=1
[../../pam_oath/pam_oath.c:parse_cfg(128)] alwaysok=0
[../../pam_oath/pam_oath.c:parse_cfg(129)] try_first_pass=0
[../../pam_oath/pam_oath.c:parse_cfg(130)] use_first_pass=0
[../../pam_oath/pam_oath.c:parse_cfg(131)] usersfile=/etc/users.oath
[../../pam_oath/pam_oath.c:parse_cfg(132)] digits=6
[../../pam_oath/pam_oath.c:parse_cfg(133)] window=30
[../../pam_oath/pam_oath.c:pam_sm_authenticate(296)] get user returned: myuser
[../../pam_oath/pam_oath.c:pam_sm_authenticate(306)] usersfile is /etc/users.oath (id 0/0)
[../../pam_oath/pam_oath.c:pam_sm_authenticate(335)] authenticate first pass rc -2 (OATH_INVALID_DIGITS: Unsupported number of OTP digits) last otp Thu May 21 11:36:30 2026
One-time password (OATH) for `myuser':
[../../pam_oath/pam_oath.c:pam_sm_authenticate(418)] conv returned: 123123
[../../pam_oath/pam_oath.c:pam_sm_authenticate(482)] OTP: 123123
[../../pam_oath/pam_oath.c:pam_sm_authenticate(490)] authenticate rc -6 (OATH_INVALID_OTP: The OTP is not valid) last otp Thu May 21 11:36:30 2026
[../../pam_oath/pam_oath.c:pam_sm_authenticate(497)] One-time password not authorized to login as user 'myuser'
[../../pam_oath/pam_oath.c:pam_sm_authenticate(530)] done. [Authentication failure]
[sudo] password for myuser:
Sorry, try again.Scan carefully through the output lines. Classic problems are formatting issues in /etc/users.oath and wrong file permissions.
Once you found and fixed the issue, don't forget to remove the debug keyword from the config file again, as the information it provides is invaluable for a potential attacker.
Registering TOTP devices
In case your TOTP authenticator application does not support typing in HEX keys directly, you can turn it into a QR code automatically.
Configure a few variables:
OTP_SERVICE=linux
OTP_USER=myuser
OTP_KEY=d73605335125ef9e4e60Replace the key with the one generated earlier, and set any user/service names you like, they only serve for you to later find the key in the authenticator app. Then generate an otpauth url and encode it as a QR code:
SECRET=$(echo -n "$OTP_KEY" | xxd -r -p | base32 | tr -d '=' | tr -d '\n')
URI="otpauth://totp/$OTP_SERVICE:$OTP_USER?secret=$SECRET&issuer=$OTP_SERVICE&digits=6&period=30"
qrencode -t ansiutf8 "$URI"The QR code will be printed directly to the terminal and can be scanned with a camera directly off the screen.
█████████████████████████████████████████████
█████████████████████████████████████████████
████ ▄▄▄▄▄ █▀ █▀▀██▄▄ ▀ █▄▄█ █▀▀▄█ ▄▄▄▄▄ ████
████ █ █ █▀ ▄ ██▄█ ▀ █ ▄▄█▄▀ ▀▀█ █ █ ████
████ █▄▄▄█ █▀█ █▄ ▀▄▀ ▀ █ █ ▀ █ █▄▄▄█ ████
████▄▄▄▄▄▄▄█▄█▄█ ▀▄▀▄▀▄▀▄█▄▀ █▄█▄█▄▄▄▄▄▄▄████
████▄▄▄▄ █▄▄ ▄▄█▄█▄ ▀▀▄▀▄█▀██ ▀▄▀ █ █▄█ █████
█████ █▀█▄ █▀▀ ▄█▀█▀ ▀▄▀▄▀█▄█ ▀ ▄█ ████
█████▀ ▄▀▄▄ █▀ ▄▀▀▀▀█▀▄▀▄█▀ ▄▄▀▄▀ ▄▀▄ ▄████
████▄▄▀▄█▀▄▄ ▀▄█▀ ▄ █▄▄▀█▄▀▀ ▄█▄▄█▀▄▀ █ ████
████ ▀█ ▀▄██▄ █▄█▄ ██▄▀▄█▀ ▀▄▀▄ ▀▄▀▄▀ █▄████
█████▄ █▀ ▄▄█ █ ▄█▀ █▄▄▀▄██ █▄▄ ██▀▀ ▄▄ ████
████▀ ▀▀ ▀▄▀█▄█▄▀▀▀▀▄ ▄█▄▀▀▀▄▄▀█▄▄▄▀▄▄ ▀▄████
████ ▀▄██ ▄ █▀ █▀ ▄▀▄█ █▀██▀█▄▀▄█ ▀ ▀▄▄ ████
████▀▀▄█▀ ▄▀▀ ██▄█▄▀▄▄▄ ▄█▀▀█▄█▄ ▄█▄▄ ▄████
████ █ ▄█ ▄ █ ▄ ▄█▀█▀ █ ▀ ▄██ ▀▀███▄ ▄████
████▄██▄█▄▄▄▀ ▀▄▀▀▀▀▄ ▄ ▄▄▀▀▀ ▄ ▄▄▄ █▄▄█████
████ ▄▄▄▄▄ █▄▄▀█▀ ▄▄██▄▀ █▄▀▀▀▀█ █▄█ ▄█▄▄████
████ █ █ █ █▄█▄▄▄▄▄▀▄▀▀█▀█ ▄ ▄ ▄▄████
████ █▄▄▄█ █ ▀ ▄█▀█ ▀▀▄▀▀▄█▄▄▄▄▀ ▀▄██▄█ ████
████▄▄▄▄▄▄▄█▄▄▄▄███▄▄▄▄█▄██▄█▄▄██▄▄▄██▄▄▄████
█████████████████████████████████████████████
█████████████████████████████████████████████Scan the code with an authenticator app and everything should be imported automatically. Ensure the time configuration of the new device is also synced over NTP to prevent drift over time.