Next-Gen Web Authentication

How to use WebAuthn, hardware keys, and passkeys

Vladimir Prus
9 min readMay 14, 2023
Photo by Chris Barbalis on Unsplash

Introduction

Authentication on the web is no fun. Passwords are often weak and reused, text messages can be both lost and intercepted, social logins have usability issues, and one-time codes did not catch up either. It would be nice to have unique unguessable credentials for every site.

Public key cryptography offered a solution for several decades. We generate a matching pair of a private key and a public key. The public key is placed on the server. When needed, the server generates a challenge, the user signs the challenge with his private key, and the server verifies the signature. The keys can’t be guessed, they can’t be found by looking at the communication, and even if the server leaks all public keys, that is not an issue. But, the keys are long and unwieldy.

Today, that old technology might be ready for wide use, due to several factors. First, the hardware authenticator devices are now affordable. Second, iOS and Android devices can now store these keys, and protect them using biometrics. Finally, the standard called WebAuthn provides a browser API that gives access to both kinds of devices.

We’ll now walk through using WebAuthn, from simple API to cryptography details. There will be three ways to create a key and login, we’ll show detailed responses, and provide some verifications of data. The complete code can be found in a demo repository, and the code fragments below will skip over non-essential parts for clarity.

Local keys in the browser

For our first experiment, we’ll build authentication that works entirely in client-side JS code. Of course, that makes no practical sense but will introduce the API. There will be two buttons, “enroll” and “login”, and they will call JS functions of the same name. Let’s start with the enrollment code:

async function enroll() {
const publicKeyOptions = {
rp: { name: "Auth test", id: "localhost" },
user: { id: new Uint8Array([1]), name: "User 1", displayName: "Alice" },
challenge: new Uint8Array([1, 2, 3, 4]),
};

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

window.credential_id = credential.rawId;
}

We ask the browser to create new public key credentials and pass the name of our site (nicknamed “relying party” in the standard), the data about the user, and a random challenge (not really random here). The browser shows the following prompt

If I continue, then depending on OS I’ll be asked for a password, a fingerprint, double-press on the smartwatch, or wink at the camera, and the JS code gets the created credentials. We save it. Note that the dialog box says “This passkey will only be saved on this device”. That’s indeed true, and we’ll address it a bit later.

The login code asks the browser whether that key id is still valid:

async function login() {
const options = {
allowCredentials: [{
id: window.credential_id,
type: 'public-key'
}],
challenge: new Uint8Array([4, 5, 7, 8]),
}

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

updateStatus("Authenticated user", new Uint8Array(assertion.response.userHandle)[0])
}

When this code is invoked, the browser again asks me for a fingerprint, and the JS code receives the “assertion” object, with cryptographic proof that I still own the key.

Server-side flow

Now it’s time to implement the proper client-server flow.

On the client, the changes are not too dramatic

  • We need to call the server to get the properties and challenge, then call the credential browser API, and then send the result to the server.
  • The credential API returns a bunch of binary arrays, so we’ll need a helper library to convert them into JSON request body

On the server, however, we should be prepared that the client will be actively evil, and must verify everything. We’ll use Golang for the server, and a WebAuthn library. For simplicity, our server will have exactly one user, and support exactly one session. With these simplifications, the code is quite brief.

fakeUser := &FakeUser{}
var fakeSession *webauthn.SessionData
var credential *webauthn.Credential

r.Post("/api/enroll/start", func(w http.ResponseWriter, r *http.Request) {
var options *protocol.CredentialCreation
options, fakeSession, err = wa.BeginRegistration(fakeUser)
if err != nil {
render.Status(request, http.StatusInternalServerError)
return
}
render.JSON(w, r, options)
})

r.Post("/api/enroll/finish", func(writer http.ResponseWriter, request *http.Request) {
response, err := protocol.ParseCredentialCreationResponseBody(r.Body)
if err != nil { ... }

credential, err = wa.CreateCredential(fakeUser, *fakeSession, response)
if err != nil { ... }

fakeUser.credentials = []webauthn.Credential{*credential}
render.Status(r, http.StatusNoContent)
})

Here, FakeUser is a type I’ve defined that returns a fixed username. It also has an array of allowed credentials, and after successful enrollment, we store a credential there. Then, we can use that credential to login

r.Post("/api/login/start", func(w http.ResponseWriter, r *http.Request) {
var options *protocol.CredentialAssertion
options, fakeSession, err = wa.BeginLogin(fakeUser)
if err != nil { ... }

render.JSON(writer, request, options)
})

r.Post("/api/login/finish", func(w http.ResponseWriter, r *http.Request) {
response, err := protocol.ParseCredentialRequestResponseBody(r.Body)
if err != nil { ... }

_, err = wa.ValidateLogin(fakeUser, *fakeSession, response)
if err != nil { ... }

render.Status(r, http.StatusNoContent)
})

For the user, everything works exactly as in the browser-only case.

Using cross-platform keys

Super-secure keys that are stored only on one device are not useful. Instead, we want something we can use to authorize everywhere.

One way to achieve that is by using hardware tokens. For example, I have a YubiKey 5C device. I can plug it into any USB-C port, touch a button, and confirm who I am. It costs roughly 50 euros, which is fairly affordable for corporate use. Another way is called “passkey”, which is a key stored on your mobile device, synchronized in your cloud account, and protected by biometrics.

To request the use of cross-platform keys, we need to slightly modify our server-side code

r.Post("/api/enroll/start", func(w http.ResponseWriter, r *http.Request) {
var options *protocol.CredentialCreation
s := protocol.AuthenticatorSelection{
AuthenticatorAttachment: "cross-platform",
}
options, fakeSession, err = wa.BeginRegistration(fakeUser, webauthn.WithAuthenticatorSelection(s))
...
render.JSON(w, r, options)
})

Now, the browser gives me quite a number of options.

Let’s review them

  • The “SM-G950F” is my Android phone. If I choose it, I get a push notification and can confirm, with a fingerprint, that I want to create a passkey.
  • The “Use a different phone” option shows a QR code. I can scan it with my iPhone, and a new passkey is created in iCloud Keychain.
  • The “USB security key” prompts me to insert my hardware key, and touch a button on it.

So, with a couple of lines of code, we’ve achieved authentication that works across devices. The question is how useable is it. Using the hardware key is actually easiest, over USB. Over NFC, I find it might take a few tries to work. The other two options also work reliably, though I am a tad disappointed that Android is ahead here with automatic selection, while iOS must use a QR code (even in Safari).

If we wish, the server can also explicitly request that the user identity be verified. We can also check whether the created key is indeed eligible for cloud backup and whether it has been actually backed up. That might be useful if we want to completely switch to using passkeys, but such verification is outside the scope of this post.

Unwinding the cryptography

So far, we just trusted the magic. Let’s peek inside and understand what is being verified. Unfortunately, here the WebAuthn standard becomes confusing. We have JavaScript objects that have byte arrays that contain binary serialization using a standard called CBOR of a further nested structure, and it has more nested custom binary formats. Therefore, I wrote a couple of functions to show the “logical” rendering of data and remove some uninteresting fields.

Login

The login verification is the easiest of the two. The server, at this point, has the credential public key for the user. It generates a random challenge, sent it to the client, and the client uses the credential private key to send the following data:

{
"authenticatorData": {
"rpid": "SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2M=",
"flags": 1
},
"clientDataJSON": {
"challenge": "jaXL_9B0y3Rh0jOxmWnYwqu8uFg6F25JXLk6M0_KaDY"
},
"signature": "MEYCIQCzP-lmG2bF_GtXqYybiGaFTims_fcQMRxsj92ciSdr0wIhAMvIqTZTxrw_MC59UiV3V0-Y6G66sbG9zWekyUI8gjFR",
"userHandle": null
}

In this response, challenge is what the server sent, rpid is the hash of the server URL, and `signature` is the cryptographic signature of the prior data, using the credential private key. It is easy for the server to check the signature, and that proves that the client still possesses the credential.

Enrollment

The initial enrollment is more challenging. First, the client should provide us with the public key for the server to store, so there are more fields in the data. Second, if we’re going to trust this credential in the future, maybe even replacing the traditional password, we might want to double-check that credentials are generated by a reliable authenticator and not some DIY device.

For the second point, the standard allows the authenticator to have its own signing key, and the full-blown verification proceeds like this:

  • The credential public key is signed by the authenticator private key
  • The response contains the authenticator model id and an authenticator certificate
  • We can use the manufacturer's root certificate, provided by the standardization body, to verify the authenticator certificate.

Let’s see how it looks in the enrollment data:

{
"attestationObject": {
"attStmt": {
"alg": -7,
"sig": "MEUCIBmy....",
"x5c": [
"MIIC2TCCA...."
]
},
"authData": {
"att_data": {
"aaguid": "2fc0579f-8113-47ea-b116-bb5a8db9202a",
"credential_id": "rjByLXbGJ...",
"public_key": "pQECAyYgASF..."
},
"flags": 65,
"rpid": "SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2M="
},
},
"clientDataJSON": {
"challenge": "ziSEozDA8EhAhqF1-bO64Z-H0f881rKaEtEbYzEMqTo",
}
}

The key data here are credential_idand public_key — that’s what we need to store on the server. The aaguid field identifies the authenticator we use. The sig field contains the signature of all prior data using the authenticator private key. Finally, the x5c field contains the authenticator certificate, including its public key. The above data is sufficient to verify that the new user credentials are created by a specific trustworthy hardware key.

From this point, things quickly go downhill.

  • The Go library we use does not perform such verification at all. We either need to give up or roll our own verification code. While I’ve done so for the demo, I would not recommend using that in production.
  • In some cases, so-called “self-attestation” is used, where the signature is made by the credential private key, not proving anything.
  • iOS authenticator does not provide any signature at all

Therefore, we can only reliably verify hardware keys. That’s rather disappointing, as we cannot whitelist or blacklist phone makers or devices.

Conclusion

In this post, we looked at how we can use the WebAuthn standard to authenticate users on the web using hardware keys and passkeys. The basic technology is there, but I’m not entirely sure about usability and security.

  • For sensitive corporate applications, hardware keys as a second factor seem like a no-brainer for me. The hardware cost is justified, and we can thoroughly verify them on the server. You can do it today.
  • For consumer apps, passkeys might be a good extra option as the second login factor or as extra authentication for sensitive operators. They are just as secure as one-time code apps, and might already be more useable due to browser support.
  • I am skeptical about replacing passwords with passkeys. Yes, a passkey in your iCloud Keychain is guaranteed to be unique and unguessable, whereas a password you saved there might be weak or reused. But then, using your phone to log in to every site, without any second factor, creates a single, very risky, failure point.

That’s it. For more detail, you can look at the accompanying demo repository. If you want to read more about WebAuthn, I’d recommend the in-depth developer guide from YubiKey. Finally, if you found this post useful, you might want to follow me here on Medium or on Twitter.

--

--