Skip to content

Building a Passwordless Authentication Library for Umbraco — Part 5: WebAuthn and Passkeys

Passwordless Logo

This is the one I was most nervous about building, and Claude did quite a bit of heavy lifting during the process. We had some detailed chats about what is involved, and it has taken quite a while to get to a point I was happy with. Magic links and OTP are, when you strip them back, just "generate a secret, send it somewhere, verify it later". WebAuthn is different, it's a proper cryptographic protocol. Claude: 'A four-step challenge-response ceremony involving the browser's WebAuthn API, an authenticator (Touch ID, Face ID, a hardware security key), and your server'.

The upside is that passkeys are the new gold standard for phishing-resistant authentication (apparently). A passkey tied to example.com cannot be used on evil-example.com, even if a member is tricked into visiting it. Compared to magic links and OTP (both of which can be phished, though it's much harder than phishing passwords), passkeys are genuinely resistant.

The downside is complexity.

What Actually Happens When You Use a Passkey

It's worth understanding the flow at a conceptual level before we talk about my implementation. There are two separate ceremonies: registration (linking a passkey to your account) and sign-in (proving you own it).

Registration

sequenceDiagram
    participant M as Browser / Authenticator
    participant S as Server

    M->>S: POST /auth/webauthn/register/options
    S->>S: Generate challenge, exclude existing credentials
    S-->>M: Challenge + options (ceremonyId)
    M->>M: Call navigator.credentials.create()
    M->>M: User verifies (biometric / PIN)
    M->>S: POST /auth/webauthn/register/complete (ceremonyId + attestation)
    S->>S: Verify attestation against challenge
    S->>S: Store credential (public key, counter, metadata)
    S-->>M: 200 OK + credential summary

Sign-In

sequenceDiagram
    participant M as Browser / Authenticator
    participant S as Server

    M->>S: POST /auth/webauthn/signin/options (optional email)
    S->>S: Generate challenge, build allow-list from member's credentials
    S-->>M: Challenge + options (ceremonyId)
    M->>M: Call navigator.credentials.get()
    M->>M: User verifies (biometric / PIN)
    M->>S: POST /auth/webauthn/signin/complete (ceremonyId + assertion)
    S->>S: Look up stored credential by ID
    S->>S: Verify assertion signature
    S->>S: Check counter hasn't regressed
    S->>S: Sign in, rotate security stamp
    S-->>M: 200 OK

The server never sees the private key, it lives on the authenticator and never leaves it. The server stores only the public key and uses it to verify signatures produced by the authenticator. This is public-key cryptography doing what it was designed for.

Challenge State and the Ceremony ID

Between the options and complete requests, the server needs to remember what challenge it issued. This is known as the challenge state. It must be stored temporarily and consumed exactly once.

I use a ceremonyId pattern: where the options endpoint generates a unique ID and stores the challenge state keyed by that ID with a short TTL (default 5 minutes). It then returns the ID to the client alongside the WebAuthn options. The client sends the ID back with the complete request:

// Options endpoint
var ceremonyId = $"pwl:webauthn:reg:{member.Key}:{Guid.NewGuid()}";
var state = new RegistrationCeremonyState(createOptions, dto.Nickname, member.Key);
await _challenges.PutAsync(ceremonyId, state, ttl: TimeSpan.FromMinutes(5));
return Ok(new { CeremonyId = ceremonyId, Options = createOptions });

// Complete endpoint
var state = await _challenges.TakeAsync<RegistrationCeremonyState>(dto.CeremonyId);
if (state is null || state.MemberKey != member.Key) return BadRequest();
// state is now consumed — TakeAsync deletes it

TakeAsync is a get-and-delete operation to ensure the challenge state can only be used once. If someone tries to replay the same ceremony ID, TakeAsync returns null and the request fails.

Storing Credentials in the Umbraco Database

I store FIDO2 credentials directly in the Umbraco database via a custom NPoco-mapped table.In the future, I might make it possible to store them in a seperate database, but while the Umbraco Members themselves are stored here, it made sense to keep them together.

Because the custom table is created via Umbraco Migrations mechanism this makes it a zero-config process to get the database table configured. No Entity Framework, no separate connection string, no extra dependencies. It just works with whatever database Umbraco is already pointing at.

Excluding Existing Credentials

During registration, the server should tell the authenticator about credentials the member has already registered. The authenticator will refuse to create a new credential with the same ID, which prevents accidental duplicates and handles the case where someone tries to register the same device twice.

The user sees this as "you've already registered a passkey on this device" rather than a confusing duplicate entry appearing in their credential list.

Email Enumeration Protection for Passkeys

The sign-in options endpoint takes an optional email address to build a credential allow-list. But if you return an error when the email isn't registered, you've created an enumeration vector so protection considerations have been added.

When a member isn't found (or has no registered credentials), the server generates decoy credential descriptors instead of returning an empty list. These look exactly like real credential IDs to the client, but are derived from the email address using a HMAC key:

private List<PublicKeyCredentialDescriptor> BuildDecoyAllowList(string email)
{
    var hmacKey = _waOptions.DecoyHmacKey;
    var credentialId = HMACSHA256.HashData(
        Encoding.UTF8.GetBytes(hmacKey),
        Encoding.UTF8.GetBytes($"decoy:{email}:0"));
    
    return [new PublicKeyCredentialDescriptor(credentialId)];
}

The ceremony state records whether the allow-list is a decoy. On the complete endpoint, if state.IsDecoy is true, we apply a fake-work delay and return 401 Unauthorized — the same response we'd give for a real credential that failed verification:

if (state.IsDecoy)
{
    await FakeWork.DelayAsync(_options.FakeWorkBudget, ct);
    return Unauthorized();
}

From the outside, an unregistered email and a registered email look identical: same response structure, same timing.

Counter Regression Detection

Claude told me about this, and wrote this explination

This is one of the FIDO2 spec requirements that most developers might not have heard of (I hadn't). Every authenticator maintains a signature counter that increments each time it produces a signature. The server records the counter value after each successful sign-in.

If the server ever receives a counter value that is less than or equal to the stored value — and the counter has ever been non-zero — it should treat this as a sign that the authenticator may have been cloned:

var counterRegressed =
    (result.SignCount != 0 && result.SignCount <= storedCredential.SignatureCounter) ||
    (result.SignCount == 0 && storedCredential.HasEverIncrementedCounter);

if (counterRegressed)
{
    // Publish notification so site admins can investigate
    await _events.PublishAsync(new PasskeyCounterRegressionNotification
    {
        MemberKey = storedCredential.MemberKey,
        CredentialId = storedCredential.CredentialId,
        StoredCounter = storedCredential.SignatureCounter,
        ReceivedCounter = result.SignCount
    });

    return Unauthorized();
}

If you clone a FIDO2 authenticator (which requires physical access and is not straightforward), both copies start signing with the same counter. But the server's stored counter reflects the highest value seen — so whichever clone tries to sign in second will produce a value lower than what's stored, and the regression is detected.

Note that many modern passkey implementations (particularly software passkeys on phones) always return a counter of 0, which makes counter-based clone detection useless for them. The HasEverIncrementedCounter column tracks whether a credential's counter has ever moved — if it's been at 0 consistently, a counter of 0 is normal behaviour, not a regression.

What This Series Will Cover

  1. [Part 1] Why I built this, what's in the box, and how it fits together
  2. [Part 2] The Core package — designing for extensibility
  3. [Part 3] Magic Links — token providers, single-use enforcement, and email templates
  4. [Part 4] Email OTP — code generation, attempt counters, and the multi-node problem
  5. [Part 5] WebAuthn/Passkeys — registration and sign-in ceremonies, credential storage (you're here)
  6. [Part 6] Security under the hood — timing attacks, constant-time comparison, rate limiting atomicity

WebAuthn is the most complex piece of this library, but hopefully this post made the core ideas accessible. The FIDO2.NET library does the heavy cryptographic lifting, my contribution is the ceremony state management, credential persistence in Umbraco, and the various enumeration/replay protections.

Part 6 is the last in the series, and it pulls together all the security techniques used across the library. It's a bit different in format, less "here's how to use this feature" and more "here's what I learned about authentication security".

As always, if you have questions, I'm on the Umbraco Discord as Nik, on Mastodon, or on X.