Authentication

Overview

The authentication procedure is similar to the procedure and divided in four steps.

sequenceDiagram actor User as User/Authenticator participant Browser participant Server Browser->>Server: I want to login! Server->>Browser: Please sign this challenge Browser->>User: `webauthn.authenticate(...)` User->>User: Local authentication <br> using device PIN, biometrics... User->>Browser: Challenge signed with private key Browser->>Server: Send signed challenge Server->>Server: Verify signature using public key Server->>Browser: Welcome!
  1. The browser requests a challenge from the server
  2. The browser triggers client.authenticate(...) and sends the result to the server
  3. The server loads the credential key used for authentication
  4. The server parses and verifies the authentication payload

1️⃣ Requesting a challenge from the server

The challenge is basically a nonce to avoid replay attacks. It must be a truly random and non-deterministic byte buffer encoded as byte64url.

import { server } from '@passwordless-id/webauthn'

const challenge = server.randomChallenge()

Remember it on the server side during a certain amount of time and "consume" it once used.

2️⃣ Trigger authentication in browser

Example call:

import { client } from '@passwordless-id/webauthn'

const authentication = await client.authenticate({
  /* Required */
  challenge: "A server-side randomly generated byte array as base64url encoded",
  /* Optional */
  allowCredentials: [{id:'my-credential-id', transports:['internal']}, ...],
  timeout: 60000
})

If you already know the supported passkeys for the account, passkey selection can be skipped with allowCredentials. Without, the platform's default passkey slection dialog will be triggered.

The following options are available.

option default description
challenge - Random byte array as base64url encoded.
timeout - Number of milliseconds the user has to respond to the biometric/PIN check.
userVerification preferred Whether the user verification (using local authentication like fingerprint, PIN, etc.) is required, preferred or discouraged.
hints [] Which device to use as authenticator, by order of preference. Possible values: client-device, security-key, hybrid (delegate to smartphone).
domain window.location.hostname By default, the current domain name is used. Also known as "relying party id". You may want to customize it for ...
allowedCredentials The list of credentials and the transports it supports. Used to skip passkey selection. Either a list of credential ids (discouraged) or list of credential objects with id and supported transports (recommended).
autocomplete false See concepts

3️⃣ Send the payload to the server

The authentication payload will look like this:

{
  "clientExtensionResults": {},
  "id": "XZg7VBiVGFZzHmC4OrTXNQ",
  "rawId": "XZg7VBiVGFZzHmC4OrTXNQ==",
  "type": "public-key",
  "authenticatorAttachment": "platform",
  "response": {
    "authenticatorData": "T7IIVvJKaufa_CeBCQrIR3rm4r0HJmAjbMYUxvt8LqAdAAAAAA==",
    "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiYmYxOWQ3ZjktZjk3ZS00NjEyLTg0MjYtNDYwZTExZmExOTBmIiwib3JpZ2luIjoiaHR0cHM6Ly93ZWJhdXRobi5wYXNzd29yZGxlc3MuaWQiLCJjcm9zc09yaWdpbiI6ZmFsc2V9",
    "signature": "MEYCIQC1FA7k7j7zf50ar9STzkanna16IkZdIYHwLNeWYWxCRwIhAITEOUcqnMC9_EHmjRxzoq3K-Titr3nWSZKY9n1yC_cL",
    "userHandle": "ZDUzMGYxMGQtZmI2ZS00ZjdkLTgzMTMtZWQ5N2QzYTU2ZDQ4"
  }
}

4️⃣ In the server, load the credential key

import { server } from '@passwordless-id/webauthn' 

const credentialKey = { // obtained from database by looking up `authentication.id`
    id: "3924HhJdJMy_svnUowT8eoXrOOO6NLP8SK85q2RPxdU",
    publicKey: "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEgyYqQmUAmDn9J7dR5xl-HlyAA0R2XV5sgQRnSGXbLt_xCrEdD1IVvvkyTmRD16y9p3C2O4PTZ0OF_ZYD2JgTVA==",
    algorithm: "ES256",
    transports: ['internal']
} as const

const expected = {
    challenge: "Whatever was randomly generated by the server",
    origin: "http://localhost:8080",
    userVerified: true, // should be set if `userVerification` was set to `required` in the authentication options (default)
    counter: 123 // Optional. You should verify the authenticator "usage" counter increased since last time.
}

Regarding the counter, it might or might not be implemented by the authenticator. Typically, it's implemented by hardware-bound keys to detect and avoid the risk of cloning the authenticator and starts with 1 during registration. On the opposite, for password managers syncing keys in the cloud, the counter is typically always 0 since in that case cloning is a "feature". For example, device-bound keys on Android and Windows do have an increasing counter, USB security keys also, while MacOS/iOS do not. Lastly, please note that the specs do not mandate "+1" increases, it could theoretically increase by any amount.

Often, it might also be more practical to use functions to verify challenge or origin. This is possible too:

const expected = {
    challenge: async (challenge) => { /* async call to DB for example */ return true },
    origin: (origin) => listOfAllowedOrigins.includes(origin),
    userVerified: true, // no function allowed here
    counter: 123,  // optional, no function allowed here
    verbose: true, // optional, enables debug logs containing sensitive information
}

5️⃣ Verify the authentication

const authenticationParsed = await server.verifyAuthentication(authentication, credentialKey, expected)

Either this operation fails and throws an Error, or the verification is successful and returns the parsed authentication payload.

Please note that this parsed result authenticationParsed has no real use. It is solely returned for the sake of completeness. The verifyAuthentication already verifies the payload, including the signature.

Remarks

Sadly, there are a few things you cannot do.

  • ❌ You cannot know if a user already registered a passkey
  • ❌ You cannot decide if the passkey should be hardware-bound or synced
  • ❌ You cannot delete a passkey

And beware of platform/browser quirks!

The specification is complex, areas like UX are left to platform's discretion and browser vendors have their own quirks. As such, I would highly recommend one thing: test it out with a variety of browsers/platforms. It's far from a consitent experience.

Moreover, otpions like hints, allowCredentials, userVerification and discoverable may interact with each other and provide different UX depending on their combination and the time of the year. The protocol evolved dramatically in the last years, with changes to the UX every couple of months.