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

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
Article 7 — A Real Passwordless PWA Flow (Architecture Walkthrough)
→ Article 8 — Implementing WebAuthn in Practice
Article 9 — Integrating OIDC (Feide) as Fallback and Recovery






