← Back to blog

Remote sudo with a FIDO2 key, Face ID and Touch ID, and the mystery a Wi-Fi restart finally solved

20 August 2026 · 5 min read

run it yourselfsecurity

The brief

I wanted to approve sudo on my home servers from an iPad, with the same physical FIDO2 key I already use for local login, without reading and typing a TOTP code for every remote change. The first route I tried was SSH agent forwarding with a hardware key (pam_rssh on the server, a FIDO2-backed SSH key in a terminal app). It does exist, but the combination of "an iPad app that can forward a FIDO2 signing request" was either behind a subscription or not properly documented anywhere.

The solution turned out simpler than that whole detour. Safari on iOS/iPadOS speaks WebAuthn (the same standard as FIDO2 and Passkeys) natively. No app or subscription is needed, just your own web page that talks to the key through the browser. So I built a small self-hosted backend (FastAPI, python-fido2) and a pam_exec script that sends a push notification with a link when sudo runs. Tapping it opens a page that starts WebAuthn verification by itself, and a touch of the key, Face ID or Touch ID confirms it.

Building it went quickly. Getting it to work reliably was a proper detective story.

First bug: seteuid runs as root, not as me

The first real test simply skipped to the TOTP prompt, with no trace of the new step. pam_exec.so has a seteuid option that switches the effective UID to PAM_USER, and during sudo authentication PAM_USER is root itself, not the user running sudo. So $HOME wasn't set to mine, the path $HOME/.config/ntfy/credentials.env resolved to nothing, and the script died before it even tried anything on the network. The fix: hardcode the path to the credentials file instead of relying on $HOME.

A feature that made things worse

I wanted to be able to skip the wait for approval (85 seconds) by pressing any key. The implementation with read -t 2 < /dev/tty in a pam_exec context ran into the fact that /dev/tty isn't a usable terminal there. read failed immediately, not after the timeout. The whole 85-second cycle passed in under two seconds of real time, with no chance to approve anything. I went back to a plain sleep.

An ntfy push that quietly failed on the second machine

The ntfy credentials file had NTFY_URL=http://127.0.0.1:8480, correct for scripts running directly on the machine with ntfy, but meaningless anywhere else, where 127.0.0.1 means that other machine itself. The fix: a public HTTPS address in the script instead of the local shortcut.

Going public forced a second, stricter look, and it paid off

Before making the repository public, I went through the code once more, properly. A real problem turned up: the /register endpoint had no authentication of its own and relied only on an IP range restriction. Anyone on the local network could have registered their own credential and gained the ability to approve sudo remotely. So I added a token that /register requires.

The same pass also revealed 16 known vulnerabilities (pip-audit) in cryptography and starlette, transitive dependencies of fido2 and fastapi. Fixing them meant a major version jump of fido2 (1.x to 2.x), which along the way removed one feature flag the code relied on, while the rest of the API stayed the same. I verified the whole flow again after this change, with the key, Face ID and Touch ID, before trusting it again.

"The notification arrives, the app shows nothing"

The server was fine. Every message was stored, with real content, without exception (checked directly in ntfy's storage). The cause was elsewhere: self-hosted ntfy can't talk to Apple's push service directly, so it sends only an empty trigger push through ntfy.sh (for privacy, message content doesn't go through a third party), and the app is meant to fetch the real content from the server itself. During quick repeated testing (several approvals in a few minutes), iOS began throttling this background fetch. The banner appeared, the content didn't. Spreading the requests out over time made the problem go away.

Last bug: "Safari can't find the server"

DNS. The domain exists only as a local Pi-hole record (on purpose, it never went into public DNS), and the phone had a stale, negative lookup stuck. Turning Wi-Fi off and on forced a new DNS lookup, and the whole flow passed cleanly on the next try. After all that searching, the fix was the oldest trick in IT support.

What stays with me

  • The seteuid option in pam_exec has its own logic: during sudo authentication PAM_USER is root, not the invoking user. Don't rely on $HOME.
  • An unchecked error can be worse than no feature at all. A read that failed immediately instead of timing out created the illusion of waiting where there was none.
  • Publishing code is a good reason to go through it again with different eyes. That's exactly when the missing authentication and outdated dependencies turned up.
  • Push notifications have their own invisible limits. iOS can throttle background content fetches when messages come too often. It looks like an app bug, but it isn't.
  • Try the simplest thing first. A Wi-Fi restart fixed what I'd been hunting across PAM, ntfy and the backend code.

The result: sudo is now approved with a tap on a notification and a touch of the key (or Face ID, or Touch ID). TOTP stays as a fallback, as intended from the start. The source code is on GitHub.