Skip to main content

Command Palette

Search for a command to run...

Passwordless: What Worked, What Didn’t, What I’d Change

Production lessons from building a passwordless-first PWA with WebAuthn, Feide OIDC, and ASP.NET Core

Updated
5 min read
Passwordless: What Worked, What Didn’t, What I’d Change

When designing a passwordless-first PWA architecture, the diagram looks elegant.

In production, elegance collides with:

  • Browser inconsistencies

  • Institutional identity constraints

  • Support tickets

  • Device lifecycle chaos

  • Monitoring blind spots

Let’s break it down honestly.


What Worked

1️⃣ WebAuthn as Primary Authentication

This worked better than expected.

Users quickly adapted to:

  • Fingerprint

  • Face recognition

  • Device PIN

Support requests about “forgot password” dropped to zero — because passwords were gone.

The combination of:

var result = await _fido2.MakeAssertionAsync(...)

and:

HttpContext.SignInAsync("Cookies", principal);

proved stable and predictable once encoding and session handling were correct.

The strongest success signal:

No phishing-related login issues after deployment.

That is not common.

Avoiding JWT-in-localStorage was absolutely the right call.

options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;

Benefits:

  • XSS impact minimized

  • Simpler revocation model

  • Clear session lifetime control

Operationally, this reduced attack surface significantly.

3️⃣ Clear Decision Tree

My initial flowchart saved me.

Because when things broke, I always knew which branch was responsible:

  • WebAuthn failure?

  • OIDC fallback?

  • Session misconfiguration?

  • Credential lifecycle issue?

That clarity matters more than people realize.


Trade-offs I Accepted Knowingly

1️⃣ No Attestation Verification

AttestationConveyancePreference.None

Trade-off:

  • No hardware manufacturer validation

  • No enforcement of hardware-backed keys

Why I accepted it:

  • Lower operational complexity

  • Better privacy posture

  • Reduced metadata dependency

In institutional context, identity assurance was already upstream via Feide.

2️⃣ Preferred Instead of Required User Verification

UserVerificationRequirement.Preferred

Trade-off:

  • Allows authenticators without biometric enforcement

  • Slightly lower strictness

Why:

  • Broader device compatibility

  • Fewer user lockouts

  • Reduced friction in older hardware environments

Security posture was balanced against accessibility.

3️⃣ No Offline Authentication

PWA expectation:
“It’s installed. It should work offline.”

Reality:
WebAuthn requires server challenge.

I chose not to simulate offline authentication using cached tokens beyond session lifetime.

Trade-off:

  • Some UX friction

  • Stronger trust model

Security > illusion of offline login.


What Looked Good on Paper But Failed in Reality

1️⃣ “Users Will Immediately Register Passwordless”

They didn’t.

Even after OIDC login, many skipped enabling passwordless.

The elegant flow:

if (!user.Credentials.Any())
    return Redirect("/enable-passwordless");

In reality:
Users ignored prompts.

Lesson:
Make passwordless enrollment prominent and incentivized.

2️⃣ Counter Strictness

Initially:

if (result.Counter <= storedCounter)
    throw new SecurityException("Possible cloned authenticator");

This caused false positives.

Some authenticators:

  • Always returned 0

  • Didn’t increment reliably

Lesson:
Spec compliance is messier than the spec implies.

Relaxed logic to handle zero counters more intelligently.

3️⃣ Browser Error Consistency

I assumed:

“All browsers implement WebAuthn uniformly.”

Reality:

  • Different error messages

  • Different cancellation behaviors

  • Slight timing differences

VueJS error handling needed refinement:

catch (err) {
  if (err.name === "NotAllowedError") {
    showRetry();
  } else {
    showFallbackOption();
  }
}

UX required careful branching.


Operational Lessons

1️⃣ Logging Matters More Than Crypto

You need logs for:

  • Challenge generation

  • Assertion verification result

  • Counter updates

  • OIDC callback mapping

  • Session creation

Example structured logging:

_logger.LogInformation("WebAuthn assertion verified for user {UserId}, counter updated to {Counter}",
    user.Id, result.Counter);

Without this, debugging failures becomes guesswork.

2️⃣ Monitoring Authentication Metrics

Track:

  • WebAuthn success rate

  • WebAuthn failure rate

  • OIDC fallback frequency

  • Credential registrations per day

  • Counter mismatch events

These reveal patterns:

  • Device compatibility issues

  • Misconfiguration

  • User confusion

Authentication is not “set and forget.”

3️⃣ Support Edge Cases

Real tickets included:

  • “My fingerprint stopped working after OS update.”

  • “I cleared my browser data and now can’t log in.”

  • “I logged in via Feide but it says no account.”

Each required:

  • Clear recovery path

  • Transparent error messaging

  • Internal documentation

Edge cases are not rare. They are normal.

4️⃣ Account Linking Confusion

Some users had:

  • Multiple institutional identities

  • Email changes

  • Duplicate accounts

Relying solely on email would have been disastrous.

Using sub claim for linking was critical:

var externalUserId = claims.FindFirst("sub")?.Value;

Stable identifiers are everything.


What I Would Change

1️⃣ Stronger Enrollment Enforcement

Instead of optional passwordless enablement:

I would require it after first successful OIDC login.

Security adoption improves when it’s default, not optional.

2️⃣ Better Device Management UI

Users should see:

  • List of registered devices

  • Last used timestamp

  • Revoke option

Backend model already supports it:

SELECT * FROM WebAuthnCredentials WHERE UserId = @UserId

But UX should surface it more clearly.

3️⃣ Structured Monitoring Dashboard

Real-time visibility into:

  • Assertion failures

  • Counter mismatches

  • OIDC errors

Would reduce reactive debugging.

4️⃣ Automated Credential Health Checks

Periodic validation:

  • Detect stale counters

  • Detect inactive credentials

  • Flag suspicious behavior

WebAuthn gives strong primitives. Monitoring must match.


The Big Lesson

The hardest part of passwordless authentication is not cryptography.

It is lifecycle management.

WebAuthn works.

OIDC works.

HTTP-only cookies work.

But the real challenge is designing:

  • Failure handling

  • Device transitions

  • Recovery paths

  • Operational visibility

Security architecture is not proven at deployment.

It is proven over time.


Final Reflection

If I rebuilt this system:

  • I would keep passwordless-first.

  • I would keep Feide federation.

  • I would keep server-controlled sessions.

  • I would invest earlier in monitoring and enrollment enforcement.

What surprised me most?

How much calmer authentication became once passwords were gone.

No resets.
No reuse.
No phishing alerts.

Just possession proof + federated identity continuity.

That combination feels less like a feature and more like an upgrade to the trust model of the application itself.

And that, ultimately, was the goal of the entire series. This concludes the series, but you can still check out my next optional extras articles next.


☰ Series Navigation

Core Series

Optional Extras

Passwordless: Modern Authentication Patterns for PWAs

Part 3 of 13

A practical deep dive into modern authentication for Progressive Web Apps. This series starts from principles — identity, trust, and user verification — and moves toward real-world passwordless systems powered by WebAuthn / FIDO2 and OpenID Connect.

Up next

Integrating OIDC (Feide) as Fallback and Recovery

Combining WebAuthn and Feide OIDC for recovery, federation, and identity continuity in a VueJS + ASP.NET Core PWA