Skip to main content

Command Palette

Search for a command to run...

Implementing WebAuthn in Practice

Implementing FIDO2 with VueJS, ASP.NET Core, SQL Server, and fido2-net-lib in production

Updated
7 min read
Implementing WebAuthn in Practice

WebAuthn looks deceptively simple at a high level:

  • Generate challenge

  • Call browser API

  • Verify signature

  • Done

In practice, it is not that simple.

WebAuthn is cryptographically elegant but operationally unforgiving.
Small mistakes create subtle security gaps or inexplicable failures.

This article walks through:

  • The tooling used

  • The data model design

  • Real code from ASP.NET Core + VueJS

  • Common pitfalls

  • And what surprised me during implementation

Disclaimer

This article describes architectural patterns and technical approaches based on a real-world implementation. All examples, code snippets, and flow descriptions have been generalized and simplified for educational purposes. No proprietary business logic, confidential configurations, credentials, or organization-specific details are disclosed. The focus is strictly on publicly documented standards (WebAuthn, OIDC) and implementation patterns within a standard VueJS + ASP.NET Core + SQL Server stack.


Tooling Used

Backend: fido2-net-lib

For .NET Core, fido2-net-lib is one of the most mature and spec-compliant WebAuthn libraries available.

It handles:

  • Challenge generation

  • Attestation verification

  • Assertion verification

  • Counter validation

  • Origin validation

  • Credential parsing

Initialization:

var fido2 = new Fido2(new Fido2Configuration
{
    ServerDomain = "yourdomain.com",
    ServerName = "Your App",
    Origin = "https://yourdomain.com"
});

The important realization:

The library handles cryptography —
You must handle state.

Frontend: Native WebAuthn API

In VueJS, no heavy library was required.
The browser already implements WebAuthn.

Registration:

const credential = await navigator.credentials.create({
  publicKey: options
});

Authentication:

const assertion = await navigator.credentials.get({
  publicKey: options
});

However:

You must convert Base64URL fields correctly between server and client.

This is one of the first places things break.


Data Model Design (SQL Server)

This is where real decisions matter.

A WebAuthn credential is not just an ID.

Here’s the simplified SQL model:

CREATE TABLE WebAuthnCredentials (
    Id UNIQUEIDENTIFIER PRIMARY KEY,
    UserId UNIQUEIDENTIFIER NOT NULL,
    CredentialId VARBINARY(MAX) NOT NULL,
    PublicKey VARBINARY(MAX) NOT NULL,
    SignatureCounter BIGINT NOT NULL,
    CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
);

Why VARBINARY?

Because:

  • Credential IDs are binary.

  • Public keys are binary (COSE format).

  • Storing them as strings introduces encoding risk.

Why store SignatureCounter?

The counter protects against cloned authenticators.

If the new counter ≤ stored counter, something is wrong.

WebAuthn security is incomplete without counter tracking.


Registration Flow (Real Implementation)

Step 1: Generate Options

[HttpPost("register-options")]
public IActionResult RegisterOptions()
{
    var user = GetCurrentUser();

    var options = _fido2.RequestNewCredential(
        new Fido2User
        {
            Id = Encoding.UTF8.GetBytes(user.Id.ToString()),
            Name = user.Email,
            DisplayName = user.Email
        },
        new List<PublicKeyCredentialDescriptor>(),
        AuthenticatorSelection.Default,
        AttestationConveyancePreference.None
    );

    HttpContext.Session.SetString("fido2.attestationChallenge", options.Challenge);

    return Ok(options);
}

Notice:

  • Challenge is stored server-side.

  • Attestation preference set to None (privacy-friendly).

  • No credentials excluded in this example.

Step 2: Verify Attestation

[HttpPost("verify-registration")]
public async Task<IActionResult> VerifyRegistration([FromBody] AuthenticatorAttestationRawResponse attestation)
{
    var challenge = HttpContext.Session.GetString("fido2.attestationChallenge");

    var result = await _fido2.MakeNewCredentialAsync(
        attestation,
        new List<PublicKeyCredentialDescriptor>(),
        (args) => args.Challenge == challenge
    );

    var credential = new WebAuthnCredential
    {
        UserId = GetCurrentUserId(),
        CredentialId = result.Result.CredentialId,
        PublicKey = result.Result.PublicKey,
        SignatureCounter = result.Result.Counter
    };

    _db.WebAuthnCredentials.Add(credential);
    await _db.SaveChangesAsync();

    return Ok();
}

Key insight:

The challenge validator delegate must explicitly check equality.

Do not assume the library does that for you.


Authentication Flow (Assertion)

Generate Assertion Options

var options = _fido2.GetAssertionOptions(
    storedCredentials,
    UserVerificationRequirement.Preferred
);

HttpContext.Session.SetString("fido2.challenge", options.Challenge);

Verify Assertion

var result = await _fido2.MakeAssertionAsync(
    clientResponse,
    storedCredential.PublicKey,
    storedCredential.SignatureCounter,
    args => args.Challenge == challenge
);

storedCredential.SignatureCounter = result.Counter;
await _db.SaveChangesAsync();

The counter update is not optional.

It is part of replay protection.


Common Implementation Pitfalls

1. Base64URL encoding mismatches

Browser returns ArrayBuffers.
ASP.NET expects byte arrays.

If encoding conversion is inconsistent, verification fails silently.

Solution: Use consistent Base64URL encoding utilities.

Example

const assertion = await navigator.credentials.get({ publicKey: options });

await fetch("/api/auth/verify-webauthn", {
  method: "POST",
  body: JSON.stringify(assertion)
});

Problem: assertion.rawId is an ArrayBuffer — not Base64URL.

Explicit conversion helpers:

function bufferToBase64Url(buffer) {
  return btoa(String.fromCharCode(...new Uint8Array(buffer)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

2. Forgetting challenge persistence

If the challenge:

  • is not stored,

  • or stored per user incorrectly,

  • or overwritten in concurrent requests,

verification fails.

Challenge must be:

  • short-lived,

  • per session,

  • non-reusable.

Example

HttpContext.Session.SetString("challenge", options.Challenge);

Then later:

var challenge = HttpContext.Session.GetString("fido2.challenge");

By using 2 different keys would introduce this bug:

Fido2VerificationException: Challenge mismatch

Or:

Fido2VerificationException: Invalid challenge.

3. Not validating origin

Origin mismatch is a common deployment issue.

If your production URL differs from development configuration, authentication breaks.

Example:

Your production:

https://app.yourdomain.com

But config says:

Origin = "https://yourdomain.com"

Subdomain mismatch would lead to this error:

Fido2VerificationException: Invalid origin

Or:

Origin https://app.yourdomain.com does not match expected https://yourdomain.com

4. Counter mishandling

Some authenticators:

  • return 0 initially.

  • do not increment as expected.

Your logic must handle legitimate zero counters.

Rejecting zero blindly causes user lockout.

Example

Authenticator returns:

counter = 0

Stored counter also:

0

Your logic:

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

Immediate lockout and return error:

Fido2VerificationException: Signature counter did not increase.

Or your own thrown exception:

Possible cloned authenticator detected.

Correct logic: Only enforce monotonicity when counter > 0.

5. Misunderstanding attestation

Attestation verifies device manufacturer.

Most applications do not need this.

Setting AttestationConveyancePreference.None:

  • avoids privacy concerns,

  • reduces complexity,

  • avoids metadata verification headaches.

Example:

You enable:

AttestationConveyancePreference.Direct

Now browser returns full attestation.

But you don’t validate metadata, which would returns:

Fido2VerificationException: Attestation format not supported

Or:

Fido2VerificationException: No metadata service configured

Bonus: Browser-Side Errors

User Cancels

DOMException: The operation was aborted.

Not Allowed

DOMException: The user aborted a request.

Unsupported Platform

NotSupportedError: The operation is not supported.

These are not backend problems — but your UX must handle them gracefully.


What Surprised Me During Implementation

1. How much state management matters

The cryptography is handled by the library.

The complexity lives in:

  • challenge storage,

  • session lifecycle,

  • device registration state,

  • error branching.

WebAuthn is less about math and more about disciplined state handling.

2. Browser inconsistencies

Different browsers:

  • format errors differently,

  • handle cancellation differently,

  • vary in UI timing.

Your retry UX must account for that.

3. The importance of fallback

The first time a device:

  • failed biometric recognition,

  • or returned unexpected counter values,

I realized:

Passwordless-only systems are fragile.

Fallback is not optional.

4. Offline expectations vs reality

Because this is a PWA, users assume:

“It’s installed. It should just work.”

But WebAuthn requires:

  • live challenge from server,

  • real-time verification.

Offline login is not true authentication.

Designing expectations around that was essential.

5. The psychological difference

Once implemented properly:

Users stopped typing passwords.

They trusted the system more.

That was not because of UI polish.

It was because:

  • no secrets were transmitted,

  • no reset emails were needed,

  • no password rules existed.

Security felt natural.

That is rare.


Final Reflection

Implementing WebAuthn is not:

  • copying code from documentation,

  • adding biometric login,

  • or flipping a feature flag.

It is:

  • modeling credentials correctly,

  • handling state carefully,

  • validating challenges strictly,

  • updating counters reliably,

  • integrating session management securely.

It is architecture expressed through code.

In the next article, we’ll examine the integration of Feide OIDC in more depth — including account linking, token validation, and how federated identity interacts with my passwordless credential lifecycle.

Because WebAuthn proves possession.

Federation proves identity continuity.

Both are required for resilient systems.


☰ Series Navigation

Core Series

Optional Extras

Passwordless: Modern Authentication Patterns for PWAs

Part 5 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

Passwordless PWA Flow Architecture Walkthrough

Implementing WebAuthn, Feide OIDC, and secure sessions in a VueJS + .NET Core PWA