My day with WebAuthn

On using standards and nothing else.

A drawing of Ada Lovelace at a computer.
Creative Computing, December 1982. Source

Identity is one of the few areas where I like to be an early adopter of new technologies, particularly things like WebAuthn. I use passkeys for any Web service that supports them, and have multiple YubiKeys to store credentials for me. The other day, though, I realized that despite understanding WebAuthn conceptually - cryptographic schemes involving signatures and challenges all look similar enough - I didn't understand much about how it really works. To fix this, I decided to try writing a WebAuthn Relying Party (RP), or a client and server that relies on WebAuthn for authentication, using as few dependencies as possible.

Before getting into the process of implementing such a Relying Party, it is first worth discussing WebAuthn in general. WebAuthn defines a uniform client and server API for exchanging public key-based credentials as a replacement for passwords in Web applications, as well as a number of different credential types and attestation mechanisms for authenticators. Most end users, myself included, interact with these APIs using passkeys, which are a form of credential defined in the WebAuthn standard. Technically speaking, "passkey" is just a more user-friendly term for a client-side discoverable credential, which is to say the authenticator stores information about the credential rather than relying on the server. Server-side credentials also exist in WebAuthn, but these are apparently not worthy of a catchy name.

I initially expected writing code that uses WebAuthn would be rather difficult. The standard is fairly large (300 pages when printed), the APIs are complex, and the terminology can be hard to follow. This last point was the most worrying at the start of the project. Passkeys, for example, are not just also known as client-side discoverable credentials. They have other names too, including discoverable credentials, resident credentials, resident keys, and client-side discoverable public key credential sources. This mixing up of historical jargon, current jargon, and user-friendly language like "passkey" occurs all over the WebAuthn specification, even within individual interfaces. Given this, I expected to be stuck with a tangled mess of clunky JavaScript APIs that would be impossible to use in the browser without pulling in some library to do everything.

This turned out to not be the case at all. In reality, WebAuthn largely ties in well with other longstanding browser standards, the bulk of which I did not fully appreciate until actually trying to write code against them. WebAuthn, and passkeys by extension, leverage APIs like Credential Management and those defined in the HTML living standard (which, confusingly enough, defines both the HTML language itself and a number of browser APIs). Thanks to the efforts of the many people who have contributed to the standardization of the Web, the entire project from start to finish took less than a day.

For the implementation itself, I decided to start with the WebAuthn standard and only the standard. As a rule, when writing software against a given set of standards I try to rely on "official" reference material as much as possible. Doing so helps me to understand the design intent of the authors of some standard or other technology, which is something secondhand sources like blog posts do not always capture. This is not to say such sources are always bad - certain things like TPM 2.0 are almost impossible to follow without some guiding commentary - but rather that trying to treat a standard as a sort of logical closed system can yield insights that might be hard to uncover otherwise.

Since this was my first deep read of WebAuthn (I had skimmed it previously), I decided to just hop around and see what stuck out to me as a good lead to follow. Fortunately, the specification includes helpful diagrams describing credential registration and authentication, as well as example code for the same ceremonies, so it didn't take long to find a starting point.

APIs (or, to use proper W3C terminology, interfaces) only tell part of the story though; something has to implement these interfaces for them to be useful. In the interest of following the standard as closely as possible, this meant crawling the other materials WebAuthn itself references on until I got to something I could actually use to write code. This was where things got particularly difficult. Going from tidy interfaces like PublicKeyCredential to secure contexts, environments, and JavaScript realms was a bit of a challenge, to put it mildly. This is probably largely inevitable when dealing with Web technologies, many of which were cobbled together from various browsers and standardized only after they became popular. Still, seeing where interface definitions fall off and leave one relying on living standards is instructive in itself.

After gathering enough material, I finally felt ready to actually try writing something. I decided for the sake of simplicity to skip browser JavaScript frameworks and just stick to the fundamentals. This, for example, is the entire HTML document for the passkey application:

<!DOCTYPE html>
<html>
  <head>
    <script src="./js/main.js" type="module"></script>
  </head>
  <body>
    <div>
      <label for="username-field">Username</label>
      <input type="text" id="username-field" />
    </div>
    <div>
      <button id="btn-register">Register</button>
      <button id="btn-login">Login</button>
    </div>
    <div id="output" style="overflow-wrap: break-word"></div>
  </body>
</html>

The client-side JavaScript was similarly basic. To start things off, I stripped down the registration example in the WebAuthn specification to only support ES256 keys and removed some options I didn't care about for testing, yielding a function that provided a full proof of concept for credential registration in only 35 lines of JavaScript:

async function registerCredential(username) {
    let challenge = new Uint8Array(32);

    window.crypto.getRandomValues(challenge);

    let userID = new Uint8Array(64);

    window.crypto.getRandomValues(userID);

    let user = {
        id: userID,
        name: username,
        displayName: username,
    };
    
    let options = {
        rp: {
            name: "Experimental Systems",
            id: "localhost",
        },
        challenge: challenge,
        user: user,
        pubKeyCredParams: [
            {
                type: "public-key",
                alg: -7,
            },
        ],
    };

    let credentialInfo = await window.navigator.credentials.create({publicKey: options});

    let infoJSON = JSON.stringify(credentialInfo);

    return `Successfully registered authenticator for ${username}! Details: ${infoJSON}`
}

After adding a couple of document.querySelector invocations and event listeners in the main JavaScript module, I had a functioning demo:

passkey-demo.png

From here I decided to actually write a simple HTTP server in Go for storing credentials and handling authentication ceremonies, since the browser WebAuthn interfaces understandably do not implement server-side functionality like assertion verification. Thanks to the excellent Go webauthn module and standard library features like method-based path matching in net/http and embedded filesystems in embed, writing a functional service that uses passkeys was easy. The code for the whole proof of concept is available on GitHub.

All in all, the implementation took me from just after breakfast to just after dinner; more than a typical workday, but not by much. Perhaps the most surprising thing was how much everything just worked. I went into the effort expecting a grueling slog writing glue code for broken APIs and came out of it with not just a better understanding of passkeys, but a more coherent mental model of browsers and the Web as a whole. In that sense, the results of this project far exceeded my expectations.

Even with some initial confusion around things like JavaScript realms and how to properly interpret an IDL definition, I found working against Web standards and supplementary material like MDN's Web API documentation to be far more pleasant than I had anticipated. I'm still not sure I like using JavaScript - it's hard to escape its historical baggage and wonky semantics - but I enjoyed getting to write code against the browser directly rather than relying on frameworks to do the job for me. It takes a lot of work to keep something as large as the Web operating coherently, and I have a newfound appreciation for the work of the many people who make that happen.