All posts
Home LabSecurityWebAuthnSelf-Hosted

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:

  1. A request hits a protected Traefik route.
  2. Traefik calls /_webauthn/auth on the proxy via forwardAuth.
  3. If a valid session cookie is present, the proxy returns 200 and Traefik forwards the request.
  4. If not, the proxy returns 401 and Traefik redirects to the login page.
  5. On the login page, the browser triggers a WebAuthn assertion — Face ID, Touch ID, or a hardware key.
  6. 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.