Authentication
Overview
The authentication procedure is similar to the procedure and divided in four steps.
- The browser requests a challenge from the server
- The browser triggers
client.authenticate(...)
and sends the result to the server - The server loads the credential key used for authentication
- 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.