TL;DR: The most disorienting part of passkey failures on Vaultwarden isn't that they fail — it's how they fail. iOS Safari swallows the WebAuthn ceremony whole and hands you back a spinner that eventually resolves to "authentication failed" or, worse, nothing at all.
📖 Reading time: ~20 min
What's in this article
- Why Passkey Auth on Vaultwarden Breaks Differently Than You Expect
- Prerequisites: What Needs to Be in Place Before You Touch Vaultwarden
- Reverse Proxy Config: The Headers WebAuthn Actually Checks
- Vaultwarden Environment Variables and Admin Panel Settings
- iOS Enrollment: Registering the Passkey Step by Step
- Gotchas That Cost Real Time
- Verifying and Monitoring the WebAuthn Flow
Why Passkey Auth on Vaultwarden Breaks Differently Than You Expect
The most disorienting part of passkey failures on Vaultwarden isn't that they fail — it's how they fail. iOS Safari swallows the WebAuthn ceremony whole and hands you back a spinner that eventually resolves to "authentication failed" or, worse, nothing at all. The Bitwarden mobile app behaves similarly: no stack trace, no actionable error code, just silence. This happens because WebAuthn failures on the client side are intentionally opaque — the browser and OS suppress ceremony details to avoid leaking information about your authenticator configuration. So you're left debugging a black box from the server side with nothing but Vaultwarden's logs to go on, which often show the request never completed the handshake in the first place.
The root cause almost always traces back to origin validation. WebAuthn is strict: the Relying Party ID (rpId) must be a registrable domain suffix of the origin making the request, and that origin must be HTTPS with a certificate the OS trust store actually accepts. Self-signed certs fail here — not just in Safari, but at the iOS system level, which rejects the WebAuthn assertion before it ever reaches Vaultwarden. Plain HTTP is a hard no regardless of what you toggle in Vaultwarden's admin panel. The DOMAIN environment variable in Vaultwarden is not cosmetic — it sets the rpId, and if it doesn't exactly match what the browser sees in the address bar (protocol, hostname, no trailing slash surprises), the ceremony fails at registration, at login, or both, inconsistently depending on iOS version.
A specific failure mode worth knowing: you can register a passkey successfully on desktop Chrome over a properly configured HTTPS origin, then find that iOS refuses to authenticate with it. This happens when the rpId resolves correctly on desktop but the iOS Bitwarden app sends a different effective origin — particularly if you're accessing Vaultwarden through a subdomain and the app's internal WebView doesn't match your reverse proxy's exposed hostname. The credential was registered against one origin fingerprint and the authentication attempt is arriving from another, so the assertion fails signature verification silently.
This guide covers the four places the chain actually breaks in practice:
- Reverse proxy configuration — specifically the headers Caddy or nginx must forward (
X-Real-IP, correctHostpassthrough) and the TLS setup that iOS will accept, which means a real CA cert, not a self-signed one — Let's Encrypt or ZeroSSL both work. - Vaultwarden environment flags —
DOMAIN,WEBSOCKET_ENABLED, and theEXPERIMENTAL_CLIENT_FEATURE_FLAGSvalue needed to unlock passkey support in current builds. - iOS enrollment steps — the order matters; enrolling from Safari on iOS versus enrolling from the Bitwarden app produces credentials with different transport hints, and only one path works reliably for subsequent app-based authentication.
- The RP ID mismatch trap — what to check in Vaultwarden's logs when the ceremony starts but never completes, and the single environment variable change that fixes it in most cases.
Prerequisites: What Needs to Be in Place Before You Touch Vaultwarden
The most common reason passkey registration silently fails on iOS isn't a Vaultwarden misconfiguration — it's a TLS certificate the OS refuses to trust. iOS enforces WebAuthn's origin validation at the system level, which means a self-signed cert gets rejected before your Vaultwarden instance even sees the assertion. You need a publicly trusted certificate: Let's Encrypt via Caddy or Certbot is the standard path. Caddy is the lower-friction option here because it handles ACME renewal automatically and its reverse proxy config is four lines.
# Minimal Caddyfile for Vaultwarden — place at /etc/caddy/Caddyfile
vaultwarden.yourdomain.com {
reverse_proxy localhost:8080
# Caddy fetches and renews the Let's Encrypt cert automatically
# No tls directive needed — it's the default for public domains
encode gzip
header /notifications/hub Connection Upgrade
header /notifications/hub Upgrade websocket
}
Vaultwarden version matters more than most self-hosting guides admit. WebAuthn support has been in the project for a while, but passkey-specific flows — credential creation with discoverable set to true, resident key storage, UV requirement handling — stabilized around the 1.30.x image releases. Before touching any iOS config, confirm what you're actually running:
# Check the image label — the tag alone doesn't tell you the build version
docker inspect vaultwarden/server:latest | grep -i version
# Or check the running container's reported version in the admin panel:
# https://vaultwarden.yourdomain.com/admin → Diagnostics → Server Version
If you're on an older pinned tag like 1.28.x or an untagged latest that hasn't been pulled in months, pull fresh and redeploy before debugging anything else. The image label inspection often shows the actual semver even when you pulled with a floating tag.
On the client side, the requirement is the Bitwarden iOS app — not the web vault opened in Safari. The web vault path technically supports WebAuthn in a browser context, but passkey login (as opposed to 2FA with a hardware key) requires the native app's passkey provider integration, which hooks into iOS's credential manager framework. That integration requires iOS 16 or later with Face ID or Touch ID enrolled and functioning. If biometric auth is disabled or set up in a degraded state, the passkey prompt either won't appear or will fail silently after Face ID timeout.
The network path requirement is the one that bites people running Vaultwarden on a LAN with a private hostname. WebAuthn ties the credential to an origin — specifically the RP ID, which defaults to the hostname of your Vaultwarden instance. Your iOS device must reach Vaultwarden over HTTPS using exactly that hostname. Split-DNS works well here: resolve vaultwarden.yourdomain.com to your internal IP on your local DNS resolver, while the public DNS record points somewhere else or doesn't exist. A Tailscale or Cloudflare tunnel also works cleanly because the device connects via a consistent HTTPS hostname regardless of physical network. What breaks things is accessing Vaultwarden by IP, by a different hostname than the RP ID, or by mixing HTTP on the local network with HTTPS externally — the credential won't validate across those origin mismatches.
Reverse Proxy Config: The Headers WebAuthn Actually Checks
The failure mode that trips up most Vaultwarden setups isn't the WebAuthn config itself — it's the reverse proxy silently mangling the headers that WebAuthn relies on to verify the origin. The iOS Bitwarden client will present a passkey prompt, you'll authenticate with Face ID, and then nothing happens. No error. The challenge just expires. Nine times out of ten, the proxy is lying to Vaultwarden about where the request came from.
Caddy: Minimal Working Config
Caddy's automatic HTTPS is genuinely useful here, but the default reverse_proxy directive doesn't forward the Host header the way Vaultwarden expects. You need to be explicit:
vault.yourdomain.com {
reverse_proxy localhost:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
# Caddy sets X-Forwarded-Proto automatically when TLS is terminated here
# but being explicit costs nothing
header_up X-Forwarded-Proto https
}
}
The Host header is what Vaultwarden uses to derive its Relying Party ID. If Caddy passes localhost or nothing, the RP ID Vaultwarden computes won't match the origin the iOS client sends in the WebAuthn assertion. That mismatch causes a silent rejection — Vaultwarden drops the challenge without logging anything useful at the default log level. You won't see a 4xx. The request just disappears.
nginx: The Proto Header Is the Actual Footgun
With nginx the Host and X-Real-IP headers are well-documented. The one that actually bites people is X-Forwarded-Proto:
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Without this, Vaultwarden sees the backend connection as HTTP
# and refuses to issue a WebAuthn challenge entirely
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# WebSocket path for the notification service
location /notifications/hub {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_http_version 1.1;
# These two are required for the WebSocket upgrade handshake
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
Vaultwarden checks whether the request arrived over a secure context before it will issue a WebAuthn challenge. It determines this from X-Forwarded-Proto. If that header is missing, Vaultwarden infers HTTP — WebAuthn requires HTTPS, so it refuses the challenge. The iOS client gets no meaningful error back and the passkey registration flow fails at enrollment. This is the most common cause of "passkeys don't work but everything else does."
WebSocket Passthrough: Don't Skip the Notification Hub
The separate /notifications/hub block above isn't optional. Vaultwarden's notification service runs over WebSocket on that path, and the passkey enrollment flow has a timeout. If the WebSocket connection drops or never upgrades because Upgrade and Connection headers aren't being forwarded, the enrollment can silently time out mid-flow. You authenticate with Face ID, the assertion is created client-side, and then the confirmation step just hangs until the challenge expires server-side. The fix is a dedicated location block with proxy_http_version 1.1 — nginx defaults to HTTP/1.0 for proxied connections, which doesn't support WebSocket upgrade.
Quick Validation Before You Touch the App
Before you even open Bitwarden on iOS, run this:
curl -I https://vault.yourdomain.com/api/config
You're looking for two things: a 200 status, and strict-transport-security in the response headers. If you get a redirect, a 502, or HSTS is missing, iOS's WebAuthn implementation will reject the flow before your credentials are ever involved — iOS enforces that WebAuthn operations only occur over origins with valid HSTS. A missing HSTS header on Vaultwarden's API endpoint means Face ID will appear to work but the assertion will be silently dropped. Caddy sets HSTS automatically; with nginx you need to add add_header Strict-Transport-Security "max-age=31536000" always; to your server block explicitly.
Vaultwarden Environment Variables and Admin Panel Settings
The most silent failure mode in this entire setup: enrollment succeeds, you scan your face, iOS says "passkey saved," and then login just returns a generic error. Nine times out of ten that's a mismatched RP ID caused by a trailing slash in DOMAIN. Vaultwarden derives the WebAuthn Relying Party ID by stripping the scheme and any path from DOMAIN — so https://vault.yourdomain.com produces vault.yourdomain.com as the RP ID, while https://vault.yourdomain.com/ produces vault.yourdomain.com/ (with the slash). iOS registers the passkey against the RP ID it saw at enrollment time, and if that doesn't match what Vaultwarden presents at login, the assertion fails. The fix is one character, but you won't find it in the error logs without LOG_LEVEL=debug.
Here's a working service block. Everything below is required unless marked optional:
services:
vaultwarden:
image: vaultwarden/server:1.30.5
container_name: vaultwarden
restart: unless-stopped
environment:
# Must exactly match what appears in the iOS browser/app address bar.
# No trailing slash. No subdirectory. This value becomes your WebAuthn RP ID.
DOMAIN: "https://vault.yourdomain.com"
# Required for real-time sync (vault clients poll over WebSocket).
WEBSOCKET_ENABLED: "true"
# Reduces noise in production; switch to debug when diagnosing WebAuthn failures.
LOG_LEVEL: "warn"
# Optional but recommended — disables new account creation after you've set up.
SIGNUPS_ALLOWED: "false"
# Optional — set to a strong random string to protect the /admin panel.
ADMIN_TOKEN: "your-very-long-random-token-here"
volumes:
# Named volume keeps your vault data outside the container lifecycle.
- vaultwarden_data:/data
ports:
# Expose only to your reverse proxy — don't bind 0.0.0.0 in production.
- "127.0.0.1:8080:80"
- "127.0.0.1:3012:3012"
volumes:
vaultwarden_data:
driver: local
Port 3012 is the WebSocket notification port. If your reverse proxy only forwards 8080, WebSocket sync breaks silently — clients still work but don't get push notifications. Your Nginx or Caddy config needs to proxy /notifications/hub to port 3012 (or /notifications/hub/negotiate to 8080, depending on Vaultwarden version). The 127.0.0.1 bind is intentional — you want your reverse proxy terminating TLS, not Vaultwarden itself, because WebAuthn requires HTTPS and the browser enforces this at the RP ID verification step.
After the container is running, hit /admin with your ADMIN_TOKEN and navigate to the Experimental section. In Vaultwarden 1.30.x the toggle is labeled "Allow Passwordless/Passkey login" and it defaults to off. The label doesn't say "WebAuthn" anywhere obvious, which is why people miss it. Enable it, scroll down, and hit Save — there's no restart required, the setting writes immediately to the database. If you skip this step, iOS will complete the passkey enrollment flow but authentication attempts will be rejected at the server with a 4xx that the Bitwarden client surfaces as a vague "an error has occurred."
One more DOMAIN gotcha worth being explicit about: this variable must match what iOS actually sees in the address bar, not what you think it should be. If you access Vaultwarden through a reverse proxy that rewrites paths — say https://yourdomain.com/vault/ — you need DOMAIN=https://yourdomain.com/vault (no trailing slash, but the path is required). Passkey RP ID validation on iOS is strict: the registered domain must be a registrable suffix of the RP ID, and any mismatch causes silent assertion failure. A dedicated subdomain like vault.yourdomain.com is significantly easier to reason about than a path-based setup, and it's what the Vaultwarden docs assume.
iOS Enrollment: Registering the Passkey Step by Step
Most passkey enrollment failures happen before you touch the app — the server isn't ready, the origin is wrong, or the container hasn't picked up the env change. Confirm those preconditions first, because the iOS enrollment flow itself is genuinely fast and mostly silent when things are configured correctly.
Open the Bitwarden iOS app and log in with your master password the normal way. Then navigate to Settings → Account Security → Two-step Login. If the Passkey option doesn't appear in that list, the server-side flag isn't active — either WEBAUTHN_ENABLED=true is missing from your env, or Vaultwarden is still running with stale config from before you added it. The option also won't appear if your DOMAIN value doesn't resolve to an HTTPS origin with a valid cert from the iOS perspective. Self-signed certs fail silently here — iOS won't tell you "bad cert", the passkey option just won't show up.
Once you tap the Passkey option and hit enroll, the ceremony is quick. iOS generates a P-256 (ES256) key pair using the Secure Enclave, prompts Face ID or Touch ID for authorization, and posts the public key credential to Vaultwarden's WebAuthn registration endpoint. The whole round trip — biometric prompt, key generation, server registration, confirmation — takes under 10 seconds on a clean local network path. If it hangs noticeably, your reverse proxy is likely buffering the WebAuthn response or there's a DNS resolution hiccup between the app and your DOMAIN.
The failure mode you'll hit most often is a bare "Security key registration failed" error with no additional context. That message almost always means an RP ID mismatch — the DOMAIN value in your Vaultwarden env doesn't match the origin the iOS app is talking to. The RP ID Vaultwarden derives from DOMAIN must be an exact registrable domain match for the HTTPS origin. If your container is set to DOMAIN=https://vault.home.example.com but you enrolled while hitting a different subdomain or an IP address, it will fail at the CBOR attestation step with no useful error surfaced to the user. Fix the env, restart the container, and retry before assuming anything is wrong with the app or iOS version.
# Restart after env fix — confirm the new DOMAIN value is live before retrying enrollment
docker compose down && docker compose up -d
# Verify Vaultwarden actually loaded the new value
docker logs vaultwarden 2>&1 | grep -i "domain\|webauthn"
# You should see something like:
# Using domain: https://vault.home.example.com
# WebAuthn is enabled
After a successful enrollment, test the full authentication path immediately — don't leave it for later. Log out of the app completely, then on the login screen choose Log in with passkey rather than entering your master password. Face ID should fire, and you should land in the vault within a couple of seconds. If you instead see "This passkey isn't valid for this site", the RP ID drifted between enrollment and authentication — the most common cause is that you changed DOMAIN after enrolling, or you're hitting the server through a different hostname than the one active during registration. The only fix is to remove the existing passkey credential from Account Security, correct the origin so it's stable, and re-enroll. WebAuthn credentials are bound to the RP ID at creation time; there's no migration path.
Gotchas That Cost Real Time
The one that will silently waste your afternoon: Cloudflare's Rocket Loader. If you're running Vaultwarden behind Cloudflare with the orange cloud enabled, Cloudflare terminates TLS and re-issues its own cert — the RP ID iOS sees is still your domain, so the WebAuthn handshake itself isn't the problem. The problem is Rocket Loader asynchronously rewriting script execution order, and certain WAF rules that inspect and occasionally mangle JSON payloads. The WebAuthn assertion response is JSON-encoded binary data, and even a single byte of corruption causes the server-side verification to throw a signature mismatch. You'll see a generic "login failed" with no useful log output from Vaultwarden. Fix it with a Page Rule targeting your Vaultwarden subdomain:
# Cloudflare Page Rule — set for your Vaultwarden subdomain
# URL pattern: vault.yourdomain.com/identity/*
# Setting: Rocket Loader → OFF
# Add a second rule:
# URL pattern: vault.yourdomain.com/api/*
# Setting: Rocket Loader → OFF
# If you have a WAF ruleset active, also add a skip rule:
# Expression: (http.host eq "vault.yourdomain.com")
# Action: Skip → All managed rules
# — or scope it narrowly to the WebAuthn endpoints if you want WAF elsewhere
The DOMAIN environment variable trap is worse because it's a data integrity issue, not just a configuration bug. The public key stored in Vaultwarden's database at enrollment time is cryptographically bound to the RP ID, which is derived from DOMAIN. Change that variable — say, you migrate from a subdomain to a naked domain, or you finally set up a proper reverse proxy hostname — and every enrolled passkey becomes permanently invalid. The credential record still exists in the database but will never verify successfully again. There is no migration path. You delete the orphaned passkeys from the Vaultwarden admin panel and re-enroll from scratch on every device. Set DOMAIN once, correctly, before any passkey enrollment happens, and treat it as immutable after that point.
# docker-compose.yml — get this right before first passkey enrollment
environment:
- DOMAIN=https://vault.yourdomain.com # must match exactly what iOS resolves
- WEBSOCKET_ENABLED=true
# Never change DOMAIN after a passkey has been enrolled against it.
# The RP ID baked into each credential is derived from this value at enrollment time.
iCloud Keychain sync for passkeys is a deliberate Apple UX choice that has real security surface implications for a password manager specifically. If iCloud syncs your Vaultwarden passkey, it propagates to every Apple device signed into that Apple ID — convenient, but it means your Vaultwarden front door inherits the security posture of your entire iCloud account. For a password manager that holds credentials for everything else you own, that's a meaningful threat model consideration. Device-bound passkeys don't sync and stay on the enrolling device only. To disable sync for Bitwarden/Vaultwarden's passkey specifically: Settings → Passwords → Password Options on iOS 17+, then manage which apps are permitted to use iCloud Keychain for passkey sync. The option isn't as granular as you'd want — iOS doesn't expose per-credential sync toggles cleanly — so the practical move is to enroll a device-bound passkey on your primary phone and keep it there deliberately.
One broader point worth flagging for anyone building this into a larger self-hosted stack: Vaultwarden with WebAuthn is one piece of a home-lab authentication layer that touches everything downstream. If you're routing secrets into automation pipelines, Workflow Automation in 2026: n8n, Zapier, and Self-Hosted Pipelines covers how services like Vaultwarden slot into a wider orchestration setup — specifically the patterns for injecting Vaultwarden-managed credentials into n8n workflows without exposing them in environment variables or node configs.
Verifying and Monitoring the WebAuthn Flow
The most actionable signal you'll get from a broken passkey flow isn't in the Bitwarden app — it's in Vaultwarden's logs. Flip LOG_LEVEL=debug in your environment, restart the container, and watch for requests hitting POST /identity/accounts/webauthn. A clean authentication returns 200. A 400 with origin mismatch in the response body is the single most common failure after a working setup breaks, and it almost always means your DOMAIN env var doesn't exactly match the origin the iOS client is asserting — scheme, hostname, and port all have to line up character-for-character.
# docker-compose snippet — flip debug temporarily, never leave it on in prod
environment:
- LOG_LEVEL=debug # generates a lot of noise; set back to warn after
- DOMAIN=https://vault.yourdomain.com # must match what Safari sends as origin
# tail logs and filter to just the webauthn endpoint
docker logs -f vaultwarden 2>&1 | grep -i webauthn
On the iOS side, Safari Web Inspector gives you a real debugger attached to the Bitwarden app's embedded web view — but only if you have a Mac with the Develop menu enabled in Safari, USB access to the phone, and Web Inspector enabled on the device under Settings → Safari → Advanced. Once attached, open the Console and trigger a passkey login. You're watching for navigator.credentials.get() to either resolve or throw. A NotAllowedError here doesn't mean the server rejected anything — it means iOS itself denied the gesture. The two culprits are the Face ID prompt timing out (roughly 60 seconds, but the effective window before the user abandons is much shorter) and a failed biometric read. Neither produces a useful error in the Bitwarden UI, so Web Inspector is the only way to distinguish "server said no" from "iOS said no."
The failure mode nobody thinks about until it bites them: TLS certificate expiry silently breaks passkey login before anything else. WebAuthn origin validation runs over HTTPS, and a cert that's even one day expired causes the iOS client to drop the connection before the challenge ever exchanges. The Bitwarden app will show a generic network error, not a cert warning. Vaultwarden's /api/config endpoint is a lightweight, unauthenticated GET that returns a JSON blob — it's a clean liveness target that exercises the same TLS stack as the auth flow without requiring credentials.
# UptimeKuma in Docker — add a monitor of type HTTP(s)
# Target: https://vault.yourdomain.com/api/config
# Interval: 5 minutes
# Expected status: 200
# Turn on cert expiry notification — UptimeKuma checks the leaf cert
# and can alert you 14 days before expiry
# Minimal docker-compose if you don't have it running yet
services:
uptime-kuma:
image: louislam/uptime-kuma:1 # pin the major version
volumes:
- ./uptime-kuma-data:/app/data
ports:
- "3001:3001"
restart: unless-stopped
One gotcha with the /api/config health check: if you're behind a reverse proxy that returns 200 on a custom error page (some nginx configs do this with proxy_intercept_errors), UptimeKuma will report green while Vaultwarden is actually down. Add a keyword check for "version" in the response body — that key is always present in a real config response and won't appear in a proxy error page. That combination of status code plus keyword match gives you a monitor that catches both container crashes and cert failures without false positives.
Disclaimer: This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.
Originally published on techdigestor.com. Follow for more developer-focused tooling reviews and productivity guides.
Top comments (0)