From Dropbox to Daily Driver Part 4: Adding TOTP Two-Factor Authentication

Part 3 got this Pi to key-only SSH: no password will get anyone in, only a private key that never left the two machines it was generated on. That’s a real barrier, but it’s a single one — anyone who gets hold of one of those private keys gets in, full stop. This part adds a second, independent factor on top: a time-based one-time code from an authenticator app, required in addition to the key, not instead of it.

Installing libpam-google-authenticator

Despite the name, this doesn’t talk to Google for anything — it’s a PAM module implementing the standard TOTP algorithm (RFC 6238), the same one nearly every authenticator app speaks:

sudo apt install -y libpam-google-authenticator

Running the setup, without publishing what it generates

The module ships with an interactive CLI, run once per account, that generates a secret and walks through a handful of policy questions:

google-authenticator

It’s worth describing what that setup actually asks, without reproducing what it actually outputs — the secret it generates, the QR code built from that secret, and the one-time list of emergency scratch codes it prints are live authentication credentials for this device, not device identifiers. Publishing them would mean publishing a working bypass for everything else in this series, so unlike a host key fingerprint, this isn’t a “trim it out to be tidy” call — it’s the same category of thing as a password.

The prompts themselves are worth walking through, though:

  • Time-based vs. counter-based tokens. Time-based (TOTP) is the standard choice — codes rotate automatically every 30 seconds, no state to keep in sync between client and server beyond a shared secret and a clock.
  • Update the credentials file. Confirms writing the new secret to ~/.google_authenticator, replacing anything already there.
  • Disallow multiple uses of the same token. Yes, always — this closes a narrow replay window where a code intercepted in transit could be reused within its 30-second validity.
  • Increase the time-skew window. Declined here. The default already tolerates a small amount of clock drift between the Pi and whatever device is generating codes; widening it further just widens the window an attacker gets too.
  • Rate-limiting login attempts. Yes — caps how many codes can be tried in a given period, so the 6-digit code space can’t just be brute-forced against an unlimited number of attempts.

That decision to decline a wider skew window leans on one assumption worth actually confirming rather than taking on faith: that this Pi’s own clock stays accurate enough for the default tolerance to matter, rather than genuinely drifting. timedatectl status settles it:

$ timedatectl status
               Local time: Sat 2026-08-22 09:23:18 BST
           Universal time: Sat 2026-08-22 08:23:18 UTC
                 RTC time: n/a
                Time zone: Europe/London (BST, +0100)
System clock synchronized: yes
              NTP service: active
          RTC in local TZ: no

System clock synchronized: yes and NTP service: active confirm systemd-timesyncd, Raspberry Pi OS’s default, is doing its job with no extra setup required. RTC time: n/a is expected too, this Pi has no battery-backed real-time clock, so its notion of the current time comes entirely from the network at every boot rather than a hardware clock it can fall back on. That makes the NTP sync this output confirms load-bearing rather than a nice-to-have, and it’s exactly why declining to widen the skew window earlier is a decision resting on something actually verified, not just assumed.

Once it finishes, the secret goes into an authenticator app (scanned from the QR code it briefly displays) and the scratch codes get stored somewhere offline and safe — they’re the recovery path if the device generating codes is ever lost.

Wiring PAM: chaining, not replacing

Getting SSH to actually ask for that code means editing two things: the PAM stack, and sshd_config. PAM first, in /etc/pam.d/sshd:

sudo sed -i 's/^@include common-auth/#&/' /etc/pam.d/sshd
echo "auth required pam_google_authenticator.so nullok" | sudo tee -a /etc/pam.d/sshd

common-auth is what normally pulls in Debian’s standard password-checking module — commenting it out here isn’t strictly necessary given password authentication is already off at the sshd level, but it removes any path back to password-based PAM auth entirely rather than relying on sshd_config alone to keep it closed. In its place, pam_google_authenticator.so is added as a required auth step.

The nullok flag on the end is doing one specific job: while this is being wired up, it lets a login without a configured TOTP secret still succeed, so a typo or a restart mid-change can’t lock the device out from a device with no ~/.google_authenticator file yet. It’s meant to come off the moment 2FA is confirmed working, which happens later in this part — leaving it in place permanently would mean anyone without 2FA configured on their account could just skip the second factor entirely.

Telling sshd to actually ask for it

PAM being wired up doesn’t matter unless sshd is told to invoke it as a genuine second, separate factor rather than an either/or alternative to the key. That’s three lines added to the same drop-in Part 3 created for the password-authentication fix, /etc/ssh/sshd_config.d/10-harden.conf:

sudo tee -a /etc/ssh/sshd_config.d/10-harden.conf <<'EOF'
KbdInteractiveAuthentication yes
AuthenticationMethods publickey,keyboard-interactive
UsePAM yes
EOF
sudo sshd -t

AuthenticationMethods publickey,keyboard-interactive is the important line — it tells sshd a login isn’t complete until both methods succeed in order, not either one on its own. KbdInteractiveAuthentication yes turns on the challenge/response mechanism PAM uses to actually prompt for and collect the code, and UsePAM yes is what lets sshd hand that challenge off to the PAM stack — and therefore to pam_google_authenticator.so — in the first place. sshd -t came back silent, confirming the combined config parses cleanly before anything gets restarted.

Restarting, and a hang that had nothing to do with PAM

sudo systemctl restart ssh
sudo systemctl status ssh

Came back active (running), no complaints in the unit’s own log. A quick sanity check — attempting a login with public-key auth explicitly disabled — failed instantly and correctly with Permission denied (publickey), confirming the service itself was fine and still refusing password-only access exactly as Part 3 left it.

The real test, a normal key-based login that should now also prompt for a code, didn’t behave nearly as cleanly: it hung. No prompt, no error, nothing — just a connection that sat there for minutes before getting cancelled.

Debugging a silent hang blind is a waste of time, so it’s worth doing properly: journalctl -u ssh -f left running on the Pi in the already-authenticated session, watching for anything to arrive server-side, while a fresh attempt from the client used ssh -vvv to show exactly how far it got before stalling. The verbose client output stopped dead right after Connecting to <host> port 22 — before any SSH banner exchange, before any authentication method was even offered. On the Pi’s side, journalctl -f showed precisely nothing for that entire window, no new connection logged at all.

That combination is the actual diagnosis, not just a symptom: if a hang happens before an SSH banner ever shows and the server-side journal never logs a corresponding attempt, the problem isn’t sshd, PAM, or the 2FA config at all — the connection never reached the device in the first place. In this case the client had been pointed at a stale address from earlier notes rather than the Pi’s current one. Once corrected to the right address, the same login attempt connected immediately and produced exactly what Part 3’s key-only setup couldn’t:

$ ssh -i pi4_ed25519 michealg@192.168.1.187
(michealg@192.168.1.187) Verification code:
Linux pi4-128g 6.18.34+rpt-rpi-v8 #1 SMP PREEMPT Debian 1:6.18.34-1+rpt1 (2026-06-09) aarch64

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Tue Aug 18 2026 from 192.168.1.50
michealg@pi4-128g:~$

Server-side, the corresponding journal entries confirmed exactly what that transcript suggests — a key accepted, followed by a genuinely separate PAM-driven check:

sshd(pam_google_authenticator): Accepted google_authenticator for michealg
sshd-session: Accepted keyboard-interactive/pam for michealg from 192.168.1.50 port 52586 ssh2
sshd-session: pam_unix(sshd:session): session opened for user michealg(uid=1000) by michealg(uid=0)

Two independent factors, both required, both logged separately — a key alone no longer opens this device.

Making the second factor mandatory

With 2FA confirmed working end to end, the temporary nullok flag from earlier can come off, closing the loophole it deliberately left open during setup:

sudo sed -i 's/pam_google_authenticator.so nullok/pam_google_authenticator.so/' /etc/pam.d/sshd

A repeat of the same login confirmed nothing broke — key, then code, then a shell, exactly as before, just with no fallback left for an account that skipped setting up a token.

What’s next

This Pi now needs a valid SSH key and a live TOTP code to let anyone in, on top of the firewall and unattended updates Part 3 put in place. Every layer so far has assumed a device reachable directly on the local network. Part 5 changes that assumption: putting this Pi behind a Cloudflare Tunnel with a Zero Trust Access policy in front of it, so it’s reachable from anywhere without ever opening an inbound port at all.