New to WebAuthn?
If you have never heard of the WebAuthentication standard before, let me tell you something: It's awesome! I've written a blog article that explains what WebAuthn is and why you should definitely use it. I recommend you to check that out first and then come back to the technical parts.
What will be covered in this article?
This article is meant as a first introduction into how to implement WebAuthn yourself. It won't cover each required methods step by step (although an article about that is currently coming up), but it will walk you through all necessary steps to implement the specification. Here's what you can expect:
What is the basic (developers) idea of WebAuthn?
From a web developers view, WebAuthn is actually quite straight-forward and only offers two functionalities: Signing up a new user and logging in an existing user. For both of these functions, it offers a method under navigator.credentials
: navigator.credentials.create()
and navigator.credentials.get()
. The credentials API also offers the methods navigator.credentials.preventSilentAccess()
which toggles the auto sign-on capabilities to your application and navigator.credentials.store()
which is meant to persist any credential (e.g. username/password) to the browser. Both of these APIs are not part of the WebAuthn standard.
The WebAuthn specification is not yet implemented in all browers (by the time this article got published). Check availability to learn more.
Implementing WebAuthn always requires a client (e.g. a browser) and a server (or, in specification terms, Relying Party). On the client-side, you have to provide some options (we will look into these later) to call create()
and get()
, which will then provide you a PublicKeyCredential. This credential contains different data depending on if you have created a new credential or requested an existing credential. The credential is delivered in a secure context by most clients, meaning it is only handed out over HTTPS and its attributes cannot be directly sent to a server. That wouldn't be a good idea anyways, as the credential object contains a wild mixture of strings and byte arrays.
Registering a new user
Step 1: Client-side signup
So let's get into the actual doing. Registering a new user starts on the client. Calling navigator.credentials.create()
will push a dialog to the user prompting him to choose an authentication method and using this method to verify themself. But before doing this, we have to configure the so-called PublicKeyCredentialCreationOptions.
The CreationOptions consist of multiple pieces of information. These are:
- The ID of your app (namingly the URL on which your app is reachable)
- The ID, name and username of the current user (The latter two will be shown to the user when he signs in)
- A server-side challenge that you can use to ensure this request is actually meant for your app
- A list of allowed public key creation options (depending on which algorithms you allow for public key creation some providers will / won't work)
- A timeout value in milliseconds
- A list of credentials that are not allowed to be created again (basically a security measure to prohibit users creating multiple accounts on the same device)
- Additional data which requirements an authenticator (e.g. Windows Hello, Touch ID) has to fulfill to be eligible to create a credential
- An indication if you wish to also get data about the authenticator and not only the new user credential
- Extensions: Basically every key-value pair you want to include, can be used for information encoding of all sorts
After calling navigator.credentials.create()
with these options set, you will receive an Authenticator Attestation Response which also implements the Credential interface (Yep, the specification loves long object names). So basically you get a credential with a credential ID and a type (this field indicates in the W3C specs the opertation you did, namingly webauthn.create or webauthn.get), some client data (like the url at which the credential was created, and your original challenge), and an attestation object. This is a byte sequence, so literally just a list of zeros and ones. This byte array contains all credential-relevant data: The public key, (again) the credential ID and some other, cryptographically relevant information.
Before you can send this credential object to your server for further operations, you have to encode the information contained in the credential into a string that you can then decode again at your server side. You should send all available data in the credential object to your server, as every attribute has some necessity for the validation of the credentials later on.
Step 2: Server-side signup
Once you've sent the data to your server, the specification then puts a lot of verification steps in place to make sure you are dealing with an actually legit request. Your first job is to verify the source of the request is actually legit.
To do so, we got the clientDataJSON that contains all source-relevant information. As this is only a stringified JSON by the time your server receives it, you just have to parse it and are ready to go.
- Its
type
field indicates for which operation this credential was actually issued, so you have to make sure that field is set to "webauthn.create". - You then must verify that your server has actually issued the
challenge
that is included in the clientData. The challenge is a random string that your server issues whenever some user (automatically) creates new CreationOptions to sign up for your service. - Make sure that the URL that scheduled this credential is actually the URL you would expect to do this. E.g. if your app runs on "https://www.awesomeapp.com" and the
origin
of the clientData is "http://phishy.scamsite.to", you would probably want to cancel the request. - Some clients will also send you a
tokenBinding
. This attribute contains information about the TLS connection over which you got your credential, and can be used to validate a secure transfer from the client to your server.
After you are done with this, you can be pretty sure the request was issued by a legit source. So we can move on to the actually interesting part - the validation of the credential we just received. To do this, we have to decode first the attestation and then the authenticatorData.
The attestation is a CBOR encoded byte array that tells you all you need to know about how the credential was issued as well as the credential itself. This is also the part where it starts getting tricky. Every authenticator can specify their own attestation, and with that the data contained as well as the validation process are completely different. By now, this led to six different Attestation Statement Formats - they vary in their data content, their verification methods and even in the format in which the verification relevant data is issued.
We also get the authenticatorData. Here we find the credential itself, information about the circumstances under which the credential was created, and an encrypted version of the Relying Party ID that we specified at creation time in the client.
- To get things started, we first decrypt the attestation and the included authenticatorData. The attestation The authenticatorData can be parsed byte by byte as the specification gives a clear format on what byte has which information.
- We then verify that the relying party that issued this credential is really us. To do so, we encrypt our relying party ID with the SHA256 algorithm and compare it to the
rpIdHash
- if the two are identical, everything is okay. - We then take care of the
flags
- binary values which describe the checks that were done before the credential was issued. We have one flag foruserPresent
. This indicates if the presence of the user was verified by showing at least one popup that the user actively clicked before the credential was created. Another flag stands foruserVerified
, indicating if the user has passed the check of the authenticator (for example provided a matching fingerprint). The server can decide whether or not to stop the process if one of these flags is false. In the scenario of sign-up, there are very limited use cases where a valid request didn't check for the user presence or their validity, so you should enforce a strict checking (as we see later in the verify section, there are use cases where users don't have to be present in order to log in). - Now we check if the public key was created with an algorithm that we allowed in the creationOptions. To do so, we can take the
credentialPublicKey.kty
value and check it against a list of allowed algorithms we specified beforehand. - We also have to make sure we didn't receive any unwanted extensions. To do so, we look through the
extensions
attribute and compare all extensions to a list of white-labeled extensions that we want in our credential. This is also a good time to process the extensions you specified.
Now comes the fun part: We are done with verifying the context and can now start to verify the attestation itself. To do so, we have six different approaches that are all described in the specification. To not double this already huge article even more, I will only describe the general idea here:
- At first, we determine which format our attestation has. We have a
fmt
attribute that tells us in a string representation which format the attestationStatement resembles. But it is actually more secure to also go through all attributes ofattestationStatement
and check if each attribute that the format specifies is included. - The attestation contains a signature. This is the unique proof that an authenticator is really who he claims to be, and every authenticator has their own methods to validate this signature.
- The attestationStatement also contains an
AAGUID
(authenticator ID). You can use this ID to make a call to an external, trustworthy service (like FIDO) and if the ID matches their record, they will return a certificate or public key back to you (called a trust anchor). This can be used to determine if the authenticator is who they claim to be. - The attestation formats use different methods to encrypt their data. You now should use the trust anchor to validate the trustworthiness of the attestation.
If you made it this far, you actually did it: You made absolutely sure that the request was legit and that there is an actual user who wants to use your service and wants to sign up for it. All you have left to do now is to store the users credentials in your database.
- First, check if the credential ID is already in use. If there already is a record for this ID in your database, deny the request and indicate that the client should issue a new credential.
- Go ahead and register the new user.
We made it! We actually registered an user to our database, 100% compliant with all current standards. You could of course just skip steps 1 - 14 (excluding step 5) and save yourself a lot of stress and scripting, but then you couldn't be sure that you are actually operating securely in your app. And as a heads up, validating a login is way easier then signing up a new user!
Validating a login request
Step 1: Client-side validation
Similar to registering a new user, we have to first build up PublicKeyCredentialRequestOptions. We don't have to specify as many parameters, though. The necessary information that you need to get a stored users credentials is:
- A server-side challenge that you can use to ensure this request is actually meant for your app
- A timeout value in milliseconds
- The ID of your app (namingly the URL on which your app is reachable)
- Additional data which requirements an authenticator (e.g. Windows Hello, Touch ID) has to fulfill to be eligible to create a credential
- A list of credentials that you would expect to receive (all credential IDs that you have stored about the user)
After calling navigator.credentials.get()
with these options set, you will receive an Authenticator Assertion Response which also implements the Credential interface (so, basically the same as with registering a new user). We get the credential ID (as string and encoded as a byte array), client data (same as in registering an user), authenticator data (reduced version of what we know from registration), a signature and an userHandle, being the ID of the user on which this credential was registered (these are new). We again encode all this information to send it securely to our server.
Step 2: Server-side validation
Now that we got all information on our server, the first thing we have to get is our user credential record from our own database. We will need the public key that should belong to that request, so we look up the credential ID in our database. After that, we can do the request origin verification we already know from registering an user. We parse the clientDataJSON and check the following (if any of these sound new to you, go to Registering a new user - Server Part):
- Is the operation (
clientData.type
) webauthn.get? - Do we recognize the provided challenge?
- Did we expect the request to come from the given origin url?
- Do we have a
tokenBinding
object and does it match our expectations regarding the TLS connection?
After this, we can validate the context of the request (again, same procedure as in the signup process). First we decrypt the authenticator data and then we check the following:
- Does the Resource Provider ID match our expectations?
- Is there an user presence flag set?
- Is there an user verification flag set?
- Does the request send us any extensions? Did we expect those?
In the last two steps, we get to the core of verifying: Does the credential match our records? To do so, we have to first create a sha265 encrypted hash of our clientDataJSON. Then we decrypt the signature that the client has sent us, and we create a joint byte array of the initial authenticator data and the hash of clientDataJSON. If we now verify this Byte Array with the public key in our records, we should get exactly the signature value (which was created in the same procedure and signed by the private key). If not, that means our public key doesn't match the private key of the authenticator and we should therefore deny the request.
In a last step, we look at the signCount
of the request. This attribute indicates how often the key was already used to log in. We initialized the signCount with a value of 0 at signup time, and if the signCount provided is lower than the number we stored, we should also deny the request.
We did it!
And that's it! We now have a secure and intuitive method for our users to access their most private data. And the best thing: It even works completely offline!
If you want to see an implementation of this protocol, you can check out this GitHub repository.
And if you want to dig even deeper into how to build WebAuthn, stay tuned! I will publish an article walking through developing a WebAuthn server step by step soon.
Top comments (0)