Registering a YubiKey with Self-hosted Vaultwarden Behind Cloudflare Tunnel

Disclosure

Yubico sent me the YubiKeys used in this article free of charge for review. This did not influence the content — my opinions are my own.

This follows on from self-hosting Vaultwarden on a ZimaBoard 2. That instance runs at https://vw.mickhat.xyz through a Cloudflare Tunnel, and the obvious next step was a hardware second factor: a YubiKey 5C Nano for the desktop, and a 5C NFC kept elsewhere as a backup.

Registering a key is four clicks. Mine failed with an error that told me nothing:

There was a problem reading the security key. Try again.

Windows never showed a dialog for a security key, which meant the key was never asked to do anything. Whatever was broken was broken before the YubiKey got involved.

You’ll need a Vaultwarden instance on public HTTPS, admin panel access at /admin, and any FIDO2-capable key. Substitute your own hostname for vw.mickhat.xyz and your own LAN address for 192.168.178.118:10380 throughout.

WebAuthn is not YubiKey OTP

The Vaultwarden admin panel has a section called Yubikey settings that asks for a Client ID, a Secret Key and a server URL. Those fields are for Yubico OTP, the older challenge-response scheme that validates against Yubico’s cloud API. A YubiKey used as a FIDO2/WebAuthn security key doesn’t need any of them, so leave all three empty.

1. Set the domain

WebAuthn binds a credential to a Relying Party ID, which Vaultwarden derives from its configured domain. Get the domain wrong and the browser refuses the request before the key is touched.

The documented way is the environment variable. In ZimaOS it goes under the app’s Environment variables → Add; in a compose file it’s another line under environment:

Key:   DOMAIN
Value: https://vw.mickhat.xyz

Use https://, no internal port, no LAN IP, no trailing slash. It has to be the exact string you type into the browser. The port mapping and the tunnel’s internal target stay on plain HTTP, and TLS terminating at the Cloudflare edge is fine:

Host 10380 → Container 80
Cloudflare service URL: http://192.168.178.118:10380

Then I checked that the variable had actually reached the container:

sudo docker inspect vaultwarden \
  --format '{{range .Config.Env}}{{println .}}{{end}}' | grep '^DOMAIN='
DOMAIN=https://vw.mickhat.xyz

Which looked right. Registration still failed.

2. The pitfall: config.json wins

The previous article mentions this in one line: settings saved through the admin panel end up in /data/config.json, and they take precedence over environment variables. That footnote turned out to be the entire bug.

/admin/diagnostics showed it:

Vaultwarden diagnostics showing Domain configuration as No Match and No HTTPS, with the server reporting http://localhost while the browser is on https://vw.mickhat.xyz

So the container had DOMAIN=https://vw.mickhat.xyz and Vaultwarden was using http://localhost, both at the same time. That’s why docker inspect was no help. It proves the variable exists, not that anything reads it.

The fix is in the admin panel. Under Settings → General settings, set Domain URL to https://vw.mickhat.xyz, save, then restart Vaultwarden. On ZimaOS that’s the restart button on the app, no shell needed. Anywhere else:

sudo docker restart vaultwarden

Vaultwarden diagnostics showing Domain configuration as Match and HTTPS, with server and browser both on https://vw.mickhat.xyz

Once a config.json exists, the admin panel is the source of truth. Adding environment variables to a container that already has stored config does nothing.

While you’re on that page: diagnostics also flagged IP header: No Match, with the config set to X-Real-IP and the server sending X-Forwarded-For. Nothing to do with WebAuthn, but under Advanced settings set the IP header to X-Forwarded-For. Without it Vaultwarden logs the proxy’s address instead of the client’s, so its rate limiting and login log aren’t much use.

3. Register the key

Getting the domain right didn’t immediately fix registration; see the section below. Once it did work, the flow is short:

  1. Close every Vaultwarden tab, reopen https://vw.mickhat.xyz, log out and back in.
  2. Settings → Security → Two-step login → Passkey → Manage.
  3. Name the key, e.g. YubiKey 5C Nano – Desktop.
  4. Click Read key.
  5. Windows asks Choose where to save your passkey. Pick Security key, not iPhone, iPad, or Android device. It offers the phone first, which is worth knowing if you’re clicking through quickly.
  6. Enter the FIDO2 PIN, then touch the key.
  7. Save.

Reveal the recovery code on the same page afterwards and store it offline, printed or on a separate encrypted stick, and not in the vault it unlocks. Without the key, a second factor or that code, the account is gone. Register the backup key the same way and keep it away from the machine the Nano lives in.

The key is only requested at login, not when unlocking a vault you’re already signed into. If you want it more often, set the vault timeout action to Log out instead of Lock.

Troubleshooting

No Windows dialog at all. The request never reached the key, so don’t reset the YubiKey. A FIDO2 reset wipes every registration on it and can’t fix a rejection that happens earlier. Check the server first: Network tab, filter for webauthn, click Read key, open the get-webauthn-challenge response. A 200 with rp.id matching the address bar clears the server. localhost, a LAN IP or a different subdomain sends you back to step 2.

A password manager extension takes the prompt. Bitwarden and similar extensions can claim passkey requests before the browser passes them to Windows. The symptom is the extension announcing something like Logged in using passkey while no OS dialog appears. Either turn off Settings → Notifications → Ask to save and use passkeys, or add the Vaultwarden hostname under Settings → Autofill → Blocked domains, which also disables the extension’s passkey prompts.

DOMException: The operation is insecure. I don’t have a clean answer for this one.

After the domain was fixed and diagnostics said Match / HTTPS, Firefox went on refusing the call. The server was fine by then: get-webauthn-challenge returned 200 and rp.id was already vw.mickhat.xyz. The browser was rejecting a challenge that was correct. Then it stopped, and I can’t point to the change that did it. Somewhere between logging out, closing tabs, restarting the container and coming back to it later, the error went away and the Windows dialog appeared.

My guess is stale browser state. A session established while Vaultwarden thought it was http://localhost isn’t a clean place to test WebAuthn against https://vw.mickhat.xyz. So: fix the domain, then throw the browser state away (log out, close every tab, clear the site data for the host, restart the browser) before deciding anything is still broken. I skipped that and spent the time on certificate exceptions and about:config instead.

If it doesn’t clear up, check window.isSecureContext in the console; if it’s false, nothing else matters until that’s sorted. Since Firefox 140 a manually accepted certificate exception blocks WebAuthn outright, and antivirus HTTPS scanning, a local proxy or an ad blocker with its own root certificate can do the same. A fresh Firefox profile via about:profiles, or Edge InPrivate, tells you quickly whether the profile is the problem. Yubico’s WebAuthn demo page rules out the key itself.

Conclusion

Both keys are registered now, and the YubiKey wasn’t involved in the failure at any point.

The bug was the domain. It was missing from the container config to start with, and then overridden by an old config.json holding http://localhost. docker inspect reported the correct value throughout, which is exactly why it didn’t help. Once the admin panel has written a config file, /admin/diagnostics is the only thing that tells you what the server actually believes.

The end of it is less tidy. Firefox kept rejecting the call after the domain was correct, then stopped, and I can’t tell you why. Stale browser state is my best guess, and it stays a guess.