Web Authentication (WebAuthn)

An extension to the Credential Management API, the Web Authentication (WebAuthn) API uses asymmetric encryption to do away with passwords, SMS verification, and other relatively cumbersome authentication measures. This protects against password phishing, data breaches, and password-reuse attacks.

In WebAuthn, a server first provides data that binds a user to a credential (a private-public keypair); this data includes identifiers for the user and organization (also known as the "relying party"). The website would then use the Web Authentication API to prompt the user to create a new keypair. It is important to note that we need a randomly generated string from the server as a challenge to prevent replay attacks.

After the generation of the keys at the client's end, the client then sends the public key (encryption key) to the server for it to be stored there while storing the private key (decryption key) locally. As the origin is used in the generation of the keys, the user is protected against illegal access by hackers from other domains.

WebAuthn is per-browser. In other words, generally, the user must use the same browser on the same device where the private key resides to log in to a registered account. For it to work across browsers, the user must synchronize the browser data.

A server would begin creating a new credential by calling navigator.credentials.create() on the client.

challenge: The challenge is a buffer of cryptographically random bytes generated on the server, and is needed to prevent "replay attacks".

rp: This stands for “relying party”; it can be considered as describing the organization responsible for registering and authenticating the user. The id must be a subset of the domain currently in the browser. For example, a valid id for this page is webauthn.guide.

user: This is information about the user currently registering. The authenticator uses the id to associate a credential with the user. It is suggested to not use personally-identifying information as the id, as it may be stored in an authenticator.

pubKeyCredParams: This is an array of objects describing what public key types are acceptable to a server. The alg is a number described in the COSE registry; for example, -7 indicates that the server accepts Elliptic Curve public keys using an SHA-256 signature algorithm.

authenticatorSelection: This optional object helps relying parties make further restrictions on the type of authenticators allowed for registration. In this example, we are indicating we want to register a cross-platform authenticator (like a Yubikey) instead of a platform authenticator like Windows Hello or Touch ID.

timeout: The time (in milliseconds) that the user has to respond to a prompt for registration before an error is returned.

attestation: The attestation data that is returned from the authenticator has information that could be used to track users. This option allows servers to indicate how important the attestation data is to this registration event. A value of "none" indicates that the server does not care about attestation. A value of "indirect" means that the server will allow for anonymized attestation data. direct means that the server wishes to receive the attestation data from the authenticator.


const publicKeyCredentialCreationOptions = {
    challenge: Uint8Array.from(randomStringFromServer, c => c.charCodeAt(0)),
    rp: {
        name: "Duo Security",
        id: "duosecurity.com",
    },
    user: {
        id: Uint8Array.from("UZSL85T9AFC", c => c.charCodeAt(0)),
        name: "[email protected]",
        displayName: "Lee",
    },
    pubKeyCredParams: [{alg: -7, type: "public-key"}],
    authenticatorSelection: {authenticatorAttachment: "cross-platform"},
    timeout: 60000,
    attestation: "direct"
};
const credential = await navigator.credentials.create({publicKey: publicKeyCredentialCreationOptions});

The credential object returned from the create() call is an object containing the public key and other attributes used to validate the registration event.

id: The ID for the newly generated credential; it will be used to identify the credential when authenticating the user. The ID is provided here as a base64-encoded string.

rawId: The ID again, but in binary form.

clientDataJSON: This represents data passed from the browser to the authenticator in order to associate the new credential with the server and browser. The authenticator provides it as a UTF-8 byte array.

attestationObject: This object contains the credential public key, an optional attestation certificate, and other metadata used also to validate the registration event. It is binary data encoded in CBOR.


console.log(credential);
/* PublicKeyCredential {
     id: 'ADSUllKQmbqdGtpu4sjseh4cg2TxSvrbcH4NSSX9...',
     rawId: ArrayBuffer(59),
     response: AuthenticatorAttestationResponse {
         clientDataJSON: ArrayBuffer(121),
         attestationObject: ArrayBuffer(306),
     },
     type: 'public-key'
} */

After the PublicKeyCredential has been obtained, it is sent to the server for validation. The WebAuthn specification describes a 19-point procedure to validate the registration data; what this looks like will vary depending on the language your server software is written in.

Parsing the clientDataJSON...
// decode the clientDataJSON into a utf-8 string
const utf8Decoder = new TextDecoder('utf-8');
const decodedClientData = utf8Decoder.decode(credential.response.clientDataJSON);
                              
// parse the string as an object
const clientDataObj = JSON.parse(decodedClientData);

console.log(clientDataObj)
/*{
    challenge: "p5aV2uHXr0AOqUk7HQitvi-Ny1....",
    origin: "https://webauthn.guide",
    type: "webauthn.create"}
*/

Parsing the attestationObject...

authDat: The authenticator data is here is a byte array that contains metadata about the registration event, as well as the public key we will use for future authentications.

fmt: This represents the attestation format. Authenticators can provide attestation data in a number of ways; this indicates how the server should parse and validate the attestation data.

attStmt: This is the attestation statement. This object will look different depending on the attestation format indicated. In this case, we are given a signature sig and attestation certificate x5c. Servers use this data to cryptographically verify the credential public key came from the authenticator. Additionally, servers can use the certificate to reject authenticators that are believed to be weak.


// note: a CBOR decoder library is needed here.
const decodedAttestationObj = CBOR.decode(credential.response.attestationObject);

console.log(decodedAttestationObject);
/*
{authData: Uint8Array(196),
    fmt: "fido-u2f",
    attStmt: {
        sig: Uint8Array(70),
        x5c: Array(1),
    }
}
*/

Parsing the authenticator data... If the validation process succeeded, the server would then store the publicKeyBytes and credentialId in a database, associated with the user.

The authData is a byte array described in the spec. Parsing it will involve slicing bytes from the array and converting them into usable objects.

The publicKeyObject retrieved at the end is an object encoded in a standard called COSE, which is a concise way to describe the credential public key and the metadata needed to use it.

1: The 1 field describes the key type. The value of 2 indicates that the key type is in the Elliptic Curve format.

3: The 3 field describes the algorithm used to generate authentication signatures. The -7 value indicates this authenticator will be using ES256.

-1: The -1 field describes this key's "curve type". The value 1 indicates that this key uses the "P-256" curve.

-2: The -2 field describes the x-coordinate of this public key.

-3: The -3 field describes the y-coordinate of this public key.


const {authData} = decodedAttestationObject;

// get the length of the credential ID
const dataView = new DataView(new ArrayBuffer(2));
const idLenBytes = authData.slice(53, 55);
idLenBytes.forEach((value, index) => dataView.setUint8(index, value));
const credentialIdLength = dataView.getUint16();

// get the credential ID
const credentialId = authData.slice(55, 55 + credentialIdLength);

// get the public key object
const publicKeyBytes = authData.slice(55 + credentialIdLength);

// the publicKeyBytes are encoded again as CBOR
const publicKeyObject = CBOR.decode(publicKeyBytes.buffer);
console.log(publicKeyObject)
/*
{   1: 2,
    3: -7,
   -1: 1,
   -2: Uint8Array(32) ...
   -3: Uint8Array(32) ...
}
*/

After registration has finished, the user can now be authenticated. During authentication, an assertion is created, which is proof that the user has possession of the private key. This assertion contains a signature created using the private key. The server uses the public key retrieved during registration to verify this signature.

id: The identifier for the credential that was used to generate the authentication assertion.

rawId: The identifier again, but in binary form.

authenticatorData: The authenticator data is similar to the authData received during registration, with the notable exception that the public key is not included here. It is another item used during authentication as source bytes to generate the assertion signature.

clientDataJSON: As during registration, the clientDataJSON is a collection of the data passed from the browser to the authenticator. It is one of the items used during authentication as the source bytes to generate the signature.

signature: The signature generated by the private key associated with this credential. On the server, the public key will be used to verify that this signature is valid.

userHandle: This field is optionally provided by the authenticator, and represents the user.id that was supplied during registration. It can be used to relate this assertion to the user on the server. It is encoded here as a UTF-8 byte array.


const publicKeyCredentialRequestOptions = {
    challenge: Uint8Array.from(randomStringFromServer, c => c.charCodeAt(0)),
    allowCredentials: [{ id: Uint8Array.from(credentialId, c => c.charCodeAt(0)),
                         type: 'public-key',
                         transports: ['usb', 'ble', 'nfc']}],
    timeout: 60000,
}

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

console.log(assertion);
/*
PublicKeyCredential {
    id: 'ADSUllKQmbqdGtpu4sjseh4cg2TxSvsv4NSSX9...',
    rawId: ArrayBuffer(59),
    response: AuthenticatorAssertionResponse {
        authenticatorData: ArrayBuffer(191),
        clientDataJSON: ArrayBuffer(118),
        signature: ArrayBuffer(70),
        userHandle: ArrayBuffer(10),
    },
    type: 'public-key'
}
*/
After the assertion has been obtained, it is sent to the server for validation. After the authentication data is fully validated, the signature is verified using the public key stored in the database during registration.
const storedCredential = await getCredentialFromDatabase(userHandle, credentialId);
const signedData = ( authenticatorDataBytes + hashedClientDataJSON);
const signatureIsValid = storedCredential.publicKey.verify(signature, signedData);
if (signatureIsValid) {
    return "Hooray! User is authenticated! 🎉";
} else {
    return "Verification failed. 😭";
}