Protect your ASP.NET site using WebAuthn Passkeys

WebAuthn is a relatively new standard for authenticating users and is an incredibly secure way to authenticate users by being phishing resistant, and not requiring passwords at all. It accomplishes this via public-key cryptography. To read more about it, check out Wikipedia. You can also see which services already support passkeys here.

What we're going to do here is protect our Simple Auth site from last time using passkeys. You don't need to read the last post to follow this guide, as we are going to implement a completely independent authentication system that works on its own. We will be utilizing Bitwarden's Passwordless service as our passkey platform.

The basic idea behind the integration is that the server will receive a username from the end user, and then receive a one time use token from Passwordless, which will then be sent back to the client where the end user can register one of their WebAuthn methods, whether that be a hardware key, a password manager with passkey support, or their browser will prompt whatever the operating system has implemented as passkey authentication.

Source code on Coderberg and on GitHub

Passwordless

Passwordless is a service by Bitwarden (which I personally recommend) that acts as a passkey service. Meaning it will handle the actual persistence of the public key and all extra user information. It is the Passkey counterpart to Supabase's authentication as a service from the last post. It also has a free tier, which is perfect for development purposes and small hobby projects. After you sign up, you'll need the base URL, public key, and private key.

PasswordlessConfig

Integration

As passkeys have specific client requirements, we will need to make use of Passwordless' JavaScript library in addition to their .Net SDK. In my opinion, the user flow is actually simple than the usual username/password flow, as there is no need to confirm your e-mail. There's simply the sign up, and login. No page to ask the user to verify their em-mail, or a verification success page. And in general passkeys are quicker to login as it's possible to configure a one touch login that's more secure than the traditional methods.

The Client

On the user sign up page, we can either install the JavaScript library via your favorite bundler, or simply import it via a module import: import { Client } from 'https://cdn.passwordless.dev/dist/1.1.0/esm/passwordless.min.mjs'. Theoretically, the sign up page could contain nothing but a single sign up button, but we'll ask the user to enter a username simply for display purposes. At this point the Passwordless library requires a unique token that is generated by your backend.

const username = e.target.querySelector('input[name="username"]').value;
const response = await fetch(
    '@Url.Action("WebAuthnSignUp", "PasskeyAuthentication")',
    {
        method: "post",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ username })
    });
const { registrationToken, userId } = await response.json();

The userId is something your server will generate as a unique ID. a GUID works well. The registrationToken is what the server receives from Passwordless. It's generated on the server because it requires passing the secret key to the service. Using these two pieces of data, you can start the part of the client flow. The actual registration of the passkey in the user's chosen passkey device.

const p = new Client({ apiKey: "@Model.PublicKey" });
const { token, error } = await p.register(registrationToken);

The call to register is what will prompt the user's browser to create and register a WebAuthn passkey with whatever the the user has installed or available. Once you receive the passkey token, you need to send it back to the server, which will make the actual call to Passwordless to actually create the user in their service.

const createResponse = await fetch('@Url.Action("CreateUser", "PasskeyAuthentication")', {
    method: 'post',
    headers: {
        "Content-Type": "application/json",
    },
    body: JSON.stringify({ userId, username })
});
const { success } = await createResponse.json();

The success is whatever you define in the server as indicating success. Here, it's simply a boolean. At this point the user should have successfully created a passkey on their system, and your server should have created the user via Passwordless, and you can redirect the user to wherever you want.

The sign in is significantly easier. It simply requires a call to the JS library's signInWithAlias function, which will prompt the user's browser to start the WebAuthn sign in flow.

const p = new Client({
    apiKey: '@Model.PublicKey'
});
const { token, error } = await p.signinWithAlias(username);
await htmx.ajax('GET', `@Url.Action("WebAuthnSignIn", "PasskeyAuthentication")?token=${token}`);

This token then needs to be passed into the backend so you can sign the user in via standard ASP.Net HttpContext sign in methods to persist the login.

Server sign in

The last piece of the puzzle is converting that passwordless token into a ClaimsPrincipal so that ASP.Net can set the correct authentication cookies. Unlike the Supabase version, we don't have a JWT that we can parse for all the relevant information. The only thing we have is the userId. The problem with that is that we don't have the username that the user chose to display in various UI screens. You will need to track this yourself somehow. In the demo application, I'm using a "fake" in-memory database that simply maps the userId to any additional profile information. In this case just the username. Beyond that, the PasskeyService is simply responsible for grabbing the username, and then constructing a ClaimsPrincipal and calling the appropriate HttpContext.SignIn method.

task {
    let username = fakeDb.GetUser(userId)
    let identity =
        ClaimsIdentity(
            seq {
                Claim(ClaimTypes.NameIdentifier, userId.ToString())
                Claim(ClaimTypes.Name, username)
            },
            CookieAuthenticationDefaults.AuthenticationScheme
        )
    let user = ClaimsPrincipal(identity)
    do! accessor.HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, user)
    return ()
}

Those are the basics of adding Passkey support to your ASP.Net site using Bitwarden's Passwordless service. The code ends up being simple than a standard sign up and login form, and due to the nature of passkeys and WebAuthn, is also much more secure. These authentication methods can be used independently of each other, or a single user can have multiple authentication methods. It's up to you what you decide to allow the user, and how to connect their various authentication methods together.

Personally, I'm hoping for much greater passkey support in general, so that there might be a general paradigm shift to how authentication is handled to be more secure and ultimately simply to use.