← Back to blog

A Security Key Instead of a Password at Boot: the Road to FIDO2 LUKS Unlock (and Eight Attempts Before It Actually Worked)

23 July 2026 · 8 min read

linuxsecurity

Part of a series on a fresh migration of a work laptop to Pop!_OS with LUKS+Btrfs and a hardware FIDO2 key — this one piece deserves its own space, because "enroll the key and you're done" turned out to be about a tenth of the actual work.

A task that sounds simple

An encrypted disk (LUKS), a password on every boot. The goal: instead of typing a password, just hold a hardware security key up and touch it. FIDO2 support in cryptsetup exists for exactly this, libfido2 has been installed for a while, and enrolling the key into a LUKS slot goes through without a hitch.

And yet — after a reboot, all that showed up was the plain old password prompt. No FIDO2 attempt, no error, nothing.

The first layer: what kind of initrd do you even have

The real cause ran deeper than a missing package. This particular system boots via initramfs-tools (busybox-based, not a systemd-based initrd), and its cryptroot script calls cryptsetup open with an explicit --key-file. With an explicit key-file, LUKS2 external tokens (FIDO2 being one of them) never even get tried — regardless of what's packed into the initrd.

The fix wasn't editing stock files, but writing a custom script that runs before cryptroot (initramfs-tools guarantees the ordering via prereqs()), tries systemd-cryptsetup attach ... fido2-device=auto itself, and if it succeeds, cryptroot sees the mapping already done and never asks for a password at all. If it fails, it silently falls back to the original password — the fallback was never supposed to break.

Looked done. Deployed, initrd rebuilt, theoretically ready. What followed was eight reboot tests, each one peeling back a different layer of the problem.

Test 1: the key doesn't blink, because it doesn't know when

First real reboot: password again, nothing else. It turned out the script printed nothing before attempting the unlock — the security key just quietly blinks its LED, with no text prompt. With no signal for exactly when to touch the key, the attempt likely timed out silently and fell back to the password exactly as the fallback was designed to.

Fix: the script now prints "touch your security key now..." before attempting. Deployed, verified directly inside the packed initrd.

Tests 2 and 3: invisible text behind the graphical logo

Next reboot — nothing again, not even the new message was visible. Explanation: the kernel cmdline had quiet splash, so Plymouth takes over the console with its own graphical framebuffer. A plain echo to /dev/console is physically written, but visually covered by the splash logo — it simply wasn't visible, even though it was happening.

The first fix attempt (plymouth display-message) ran into the fact that the current version/theme doesn't have that command among the valid ones at all. Instead of chasing the exact Plymouth API, the decision fell to a more universal mechanism: plymouth hide-splash before the attempt (drops the graphics down to a bare text console) and show-splash afterward — independent of the specific theme or version.

Test 4: a false victory from an old attempt

This is where the most treacherous moment of the whole saga happened. The boot looked successful — the PIN prompt showed up, the key got touched, the system unlocked without a password. Logged as solved.

It was a mistake. Another "ordinary" reboot a few minutes later jumped straight back to the password prompt. Retroactive diagnosis via journalctl --list-boots revealed that the successful boot had run on a completely different initrd (initrd-dracut-fido2.img) — a separate, older, never properly documented experiment with dracut instead of initramfs-tools that I'd started myself at some earlier point and then forgotten about. It worked because dracut runs with full systemd as PID1, which handles the FIDO2 PIN prompt (via systemd ask-password) automatically. It had been a one-time boot-menu selection, not an actual fix — the next reboot went back to the original, still-broken initramfs-tools variant.

The lesson, written down on the spot: keeping a running log of your own progress isn't a formality — without it, one line of work has no idea about another, and a success on the wrong branch is easy to mistake for a solved problem.

Tests 5-6: a race against time, and the decision to kill the splash for good

Another hypothesis: maybe the failure is too fast to catch with the eye. A forced sleep 1 added around the attempt. Key physically confirmed plugged in. Reboot — completely nothing again.

That ruled out both the "too fast" theory and the "bad initrd" theory (directly verified with lsinitramfs that the script and libraries really were in there). Instead of chasing Plymouth versions any further, the decision landed on a blunter but more reliable move: remove splash from the kernel cmdline permanently. Plymouth then never takes over the console at all, so whatever gets printed will always be visible — independent of which version/theme happens to support which API.

Test 7: finally, a readable error

The first reboot without splash finally produced what had been missing the most — a specific error message: Device UUID=... does not exist or access denied. crypt_init() failed: Block device required.

The script deliberately runs before cryptroot, which means before udev even gets around to creating /dev/disk/by-uuid/<UUID> for the LUKS partition. A one-shot udevadm settle doesn't guarantee the "add" event was actually processed yet on a fast boot. Fix: active polling for the existence of the by-uuid path (every 100 ms, up to 10 s) instead of a single settle call.

Test 8: three bugs at once, caught by watching the console directly

With a readable console, all it took was watching the screen closely after each reboot — and three more, previously invisible bugs turned up at once:

  1. UUID=xxx is /etc/crypttab syntax, which the systemd-cryptsetup binary itself doesn't translate — it needs /dev/disk/by-uuid/xxx given to it directly.
  2. The keyfile argument - triggered an interactive password prompt instead of the FIDO2 path — the correct value (confirmed directly from systemctl cat on the unit) is the literal string none.
  3. After fixing both of those, the LUKS attach finally started correctly, but then froze with no PIN prompt and no key blink. Cause: the FIDO2 PIN goes through systemd's ask-password mechanism, which needs a running agent to handle it on the console. The dracut initrd (the one "accidentally successful" test above) has full systemd as PID1 and handles this automatically. initramfs-tools runs on the busybox init — none of that exists there on its own, it has to be added by hand.

Fix: a background systemd-tty-ask-password-agent --watch added, started before the attach call itself.

The real victory — and why it had to be verified independently

The fifth real test with this version finally worked the way it was supposed to from the start: PIN blind, touch the key, unlock without a password, straight to the login screen. After the false-positive experience from test 4, this time came with independent verification too — journalctl --list-boots, uptime -s, and /proc/cmdline confirmed a fresh boot on the default systemd-boot entry, no one-off alternative selection. This wasn't a lucky success on the wrong branch — it was a repeatable, ordinary path.

A small final touch-up followed: the PIN was showing up readable while typing (a cosmetic security gap) — fixed with a manual stty -echo/stty echo around the ask-password agent's run. And the edge case with no key plugged in was separately verified too: the PIN prompt shows up, fails, and correctly falls back to the normal password, exactly as designed.

Bonus round: why the login screen never asked for a touch, even though sudo did

Once the disk unlock at boot was working, another question came up — why doesn't the login screen (greeter) ask for a key touch, when that used to work on the old system. Comparing against the old disk (mounted read-only) showed the difference: on the old system, the pam_u2f.so line lived in /etc/pam.d/common-auth, which every PAM service includes, login greeter included. On the new system, the same line had only been added to the standalone sudo file — it never made it into common-auth during the migration, since that's a root-owned change outside the usual $HOME transfer.

Instead of editing the global common-auth (which would affect every PAM service at once), the line was added scoped, directly into the login greeter's own configuration, with a proper fallback to the password on failure.

What's left as a lesson

  • "Enroll the key" is only step one. Whether it actually works on every reboot depends on the initrd type, the boot splash tool, and whether full systemd or just busybox init is running.
  • A splash screen can hide even working code. If something "doesn't work" without a trace, check first whether you can even see it — not whether it's running.
  • A false positive is worse than an obvious failure. Checking "which exact boot entry did this test run on" saved from wrongly closing the case a second time.
  • Keeping a running log isn't bureaucracy. That's exactly what revealed that the "success" had come from a completely different, parallel branch of work.
  • A direct look at the console is a diagnostic tool, not a last resort — when you have no log, what you can see on the screen is often the only evidence you've got.

The result cost eight rounds of testing, but today it's just: hold up the key, touch it. The password remains only as a fallback — exactly as it should have been from the first attempt.

Dealing with something similar?

This is exactly the kind of work I take on for others too — Linux servers, Docker deployments, backups, and secure access setup.

View Linux & Docker services