From Passwords to Passkeys: Replacing oauth2-proxy with webauthn-proxy
My home lab used oauth2-proxy backed by an OIDC provider for years. Here's why I ripped it out and replaced it with a passkey-only forward-auth proxy — and how the migration went.
My public-facing home lab services used to sit behind oauth2-proxy — the standard choice for adding a login wall in front of anything running behind Traefik. It worked. But every time I hit the login page I was reminded of everything it required just to exist.
This is the story of swapping it for webauthn-proxy — a minimal FIDO2/passkey forward-auth proxy I built — and why I'm not going back.
What oauth2-proxy actually requires
On the surface, oauth2-proxy sounds simple: add a label to a Traefik router and you get a login screen. In practice, you also need:
- An OIDC provider to handle the actual authentication. Most people run Authentik or Authelia, which are full-blown identity platforms with their own databases, email servers, and configuration surfaces.
- Client credentials — a client ID and secret issued by the OIDC provider, stored as secrets in Docker Compose.
- A cookie encryption secret, rotated manually and stored somewhere safe.
- The OIDC provider itself to be publicly reachable (or at least reachable from Traefik), adding another surface in the trust chain.
For a home lab with a handful of services and a single user (me), this is a lot of moving parts to keep running just to answer the question "is this person allowed in?"
And at the end of all that, you're still logging in with a username and password. A strong one, hopefully. But still a credential that lives in a database and can be phished.
The breaking point
The real frustration came when I rotated the OIDC provider's signing keys during an upgrade and spent an evening debugging why oauth2-proxy was throwing 500s. The root cause was a mismatch between the cached OIDC discovery document and the new key set. Fixable, but entirely avoidable complexity.
I wanted something simpler. I also wanted to stop using passwords entirely.
WebAuthn and the no-password path
WebAuthn (the W3C standard behind passkeys) lets a device prove identity using a hardware-backed key pair. The private key never leaves the device — not even to the server. Authentication is a cryptographic challenge-response: the server sends a random challenge, the authenticator signs it with the private key, the server verifies with the stored public key.
For a single-user home lab, this is a perfect fit:
- No OIDC provider. No database of hashed passwords. No session tokens exchanged over the wire.
- The "credentials" are Face ID on my phone or a fingerprint on my laptop — they already exist.
- A stolen session cookie can't be replayed without the hardware authenticator.
What forward auth actually means
Before getting into the replacement, it's worth being precise about what both proxies do — because "forward auth" is jargon that gets used without much explanation.
Traefik's forwardAuth middleware intercepts every incoming request before it reaches the destination service and asks an external service: is this request authenticated? The external service replies with either 200 OK (let it through) or 401 Unauthorized (block it and redirect to login). That external service is the "forward-auth" component — it doesn't serve the application, it just makes a gate decision.
Browser → Traefik → [forwardAuth check] → Service
↓
auth-service: 200 or 401
The key property of this pattern is that authentication lives entirely at the proxy layer. Immich, Jellyfin, and Vaultwarden don't know or care about authentication — Traefik enforces it before a request ever reaches them. One auth service, one config block, everything protected.
Both oauth2-proxy and webauthn-proxy implement this interface. The difference is what happens when a user actually needs to prove their identity.
webauthn-proxy: the replacement
I wrote webauthn-proxy as a drop-in forward-auth backend for Traefik that speaks WebAuthn instead of OAuth2.
The core flow:
- A request hits a protected Traefik route.
- Traefik calls
/_webauthn/authon the proxy viaforwardAuth. - If a valid session cookie is present, the proxy returns
200and Traefik forwards the request. - If not, the proxy returns
401and Traefik redirects to the login page. - On the login page, the browser triggers a WebAuthn assertion — Face ID, Touch ID, or a hardware key.
- The proxy verifies the assertion against the stored public key and issues a session cookie.
# The entire Traefik middleware config
webauthn-auth:
forwardAuth:
address: "http://webauthn-proxy:4180/_webauthn/auth"
trustForwardHeader: true
authResponseHeaders:
- "X-Webauthn-User"
One middleware label on any router and it's protected.
The proxy self-locks after the first credential is registered — there's no permanently open /register endpoint. To add a new authenticator, you temporarily enable registration mode, enroll the device, then disable it again.
The migration
The actual migration was straightforward. For each protected service I had three labels pointing to the old oauth2-proxy middleware:
- "traefik.http.routers.immich.middlewares=oauth2-auth@docker"
I replaced them with:
- "traefik.http.routers.immich.middlewares=webauthn-auth@docker"
Then I stopped the oauth2-proxy and OIDC provider containers, removed their stacks from the repo, and committed. The GitOps agent picked up the change and applied it within a few minutes.
The Authentik stack — roughly 4 containers, a PostgreSQL instance, and a Redis instance — was gone. The oauth2-proxy container was gone. In their place: one container, no database, no secrets beyond the session signing key.
What I lost
OAuth2-proxy with a full OIDC provider does offer things webauthn-proxy doesn't:
- Multi-user support. Authentik can manage a team. webauthn-proxy is deliberately single-user.
- Fine-grained access control. OIDC providers can gate individual services per user or group. webauthn-proxy is all-or-nothing.
- Audit logs. Authentik keeps a full login history. webauthn-proxy logs to stdout.
For a personal home lab, none of these matter. If you're running infrastructure for a household or a small team, you'd want to think harder about whether a full OIDC provider is worth the operational cost.
What I gained
- No passwords anywhere in the auth chain. Logging into Immich from outside my network is a fingerprint tap.
- No OIDC provider to maintain. Authentik upgrades, Authentik migrations, Authentik "why is the flow broken" debugging — all gone.
- Phishing-resistant by construction. WebAuthn credentials are bound to the origin. A phishing page can't intercept them.
- Dramatically simpler stack. One container instead of five. One secret instead of four.
The home lab is the place where I get to make these tradeoffs deliberately. For me, a single-user passkey gate is the right answer. It took one afternoon to build and migrate, and I've had zero auth-related incidents since.
Source: github.com/ashishbhatiya18/webauthn-proxy — the full Docker Compose stack and setup instructions are in the README.