Passwordless Authentication

Article's main picture
  • #Security

• 18 min read

Introduction

Passwords are the original sin of authentication on the web. Strong and unique passwords are hard to remember and still phishable, while memorable passwords are easy to guess, and people often use one such password across several different accounts.

The introduction of the second authentication factor does fix most of the security issues above but makes the authorization process more complicated.

Authorization through other providers is one of the attempts to implement authentication without a password. However, it has multiple disadvantages on its own: the need to support many providers, the issue of correctly merging the accounts of a user with different emails (for example, the user cat can use [email protected] for GitHub and [email protected] for Facebook), last but not least comes the cluttered UI.

Passkeys is an authentication method based on biometric recognition. By using Touch ID and Face ID, available on over a billion devices, for authentication with passkeys, a user gets a better experience than in a password-based flow, plus security problems like weak and reused credentials, leaks, and phishing are no longer possible.

The web authentication specification, which defines the requirements for acceptable credentials — like passkeys — for strongly authenticating users, is still in draft, so there may be changes in the future.

Authentication without passkeys

Passkey credentials are always attached to an existing user, so some user registration flow must already be in place before using passkeys for authentication. This registration flow can also be useful when a user does not have any biometric scanners on their device (so passkeys cannot be used in principle) and can be based on a no-password authentication method.

This way, we can eliminate the need for passwords from the start, for example, by implementing an email-only authentication with a "Magic Link" method, which we showcase below.

Registration flow:

  • User fills in their email address and submits the form.
  • Server checks that the user with this email doesn't exist.
  • Registration button is shown to the user.
  • Server sends a verification letter to the email address on the registration button click.
  • User opens the email and clicks the verification link. Server creates a new account and redirects the new user to a private page, for example, a user profile.

Login flow:

  • User fills in the email address and submits the form.
  • Server checks that there is a user with this email.
  • Login button is shown to the user.
  • Server sends a sign-in letter to the email address on the login button click.
  • User opens the email and clicks the sign-in link.
  • Server redirects the existing user to a private page, for example, a user profile.

There are a lot of articles on the web about the technical details of Magic Link authentication method implementation.

This method can be used for authentication by itself, but it has some disadvantages:

  • The authentication email might get into spam, making it challenging for the user to understand what is wrong.
  • Each time during login, the user needs to interact with an email client. It's even worse if the user needs to log in on somebody else's device.

Using passkeys in an additional authentication method would eliminate these disadvantages altogether.

Using passkeys

Before we continue, let's clarify several passwordless authentication terms that are often confused:

  • FIDO - Fast Identity Online - the consortium that develops secure, open, and phishing-proof passwordless authentication standards
  • FIDO2 - a passwordless authentication protocol
  • WebAuthn - a specification for browser API written by W3C and FIDO with the participation of other companies
  • passkeys - implementation of WebAuthn, which involves credentials sharing between devices (for example, with iCloud)

Registering passkeys

Passkeys need to be registered before they are used in an authentication flow: the authenticator creates a pair of keys (private and public), the public key should be validated and saved on the server side and the private key is stored on the authenticator side.

There are two common ways to suggest users to provide biometric data:

  • The first one is right after the user is registered (after checking whether the client supports WebAuthn at all):

  • The second one, which can be used for existing users, is typically the "Add passkeys" button somewhere in the user profile interface:

For the second case, the client and the server should correctly follow the WebAuthn specification to avoid multiple registrations for the same device (excludeCredentials parameter in registration options).

Passkey registration is as simple as scanning a fingerprint or a face. Below are examples of registration flow for an iOS application:

Starting with macOS Ventura, all passkey credentials are shared through iCloud. It is important to note that different authenticators on the same device can have their own private keys. For now, Chrome and Safari don't share credentials among themselves. It is important to consider during development that the relationship between the user and their credentials is one-to-many, not one-to-one, as with a password.

Authentication flows

Two common login scenarios with WebAuthn are Modal UI and Conditional UI.

  1. User fills in the username (or their email, depending on what is used by the server as a unique identifier).
  2. Server checks if the user has previously registered some public keys and returns to the client allowCredentials options key with all the list ids of the user's public key identifiers.
  3. Client calls WebAuthn getCredentials method with this ids list.
  4. If the authenticator (for example, a browser) has one of these credentials registered, it will ask the user for a biometric scan.
  5. In response to a successful biometric scan, the authenticator returns the signature and all other data needed for the server to be verified by a previously registered public key.

Conditional UI flow

Conditional UI flow is based on autofill and is supported on the new macOS Ventura. A browser can check if this flow is supported with .isConditionalMediationAvailable() method. With Conditional UI flow, the client does not need to send a username or email to the server. The browser will auto-suggest all accounts that previously registered credentials for this website. The server can filter this list with allowCredentials option if the client sends a username or email, but it is not required. To retrieve data for autocomplete, the getCredentials method, in this case, should be called as soon as possible on the page.

The previous Modal UI flow example can be easily transformed into Conditional UI flow (technical details will come a little later):

The browser retrieves all available passkeys for the current website from iCloud — Suggests them to autofill — Scans Touch ID — All done.

If the user hasn't registered passkeys or the client (browser) doesn't support passkeys at all, the application can go the backup way with Magic Link authentication — no passwords are used in any case here.

Login by phone

Passkeys brought one more feature to WebAuthn - authentication by QR code. The feature comes in handy when the user needs to log in on someone else's computer without sharing any credentials (making unnecessary "Do not remember me" checkbox). Logging in with a QR code works by default between devices (for now, only the ones with the latest versions of iOS and macOS can do this) without a single extra line of code.

Communication between devices is handled securely by Bluetooth, and sharing or stealing the QR code will not lead to authentication.

Sharing credentials

Users can also share passkeys by AirDrop. Here is an example of sharing passkeys through AirDrop from Apple Keynote:

Most likely, applications that are open almost all the time and keep a limit of active sessions will not care about this. Also, sharing credentials will most likely not bother native apps that link their license to the device id. But for browser applications that perform some action for a short time and close immediately (for example, BrowserStack), sharing one user account between multiple people is not a very good idea for license reasons.

Removing passkeys

Before macOS Ventura removing passkeys from Safari was not possible. Now passkeys can be managed from the Passwords settings in Safari:

Keychain Access doesn't seem to know anything about passkeys yet.

Technical details

Let's define more terms before proceeding with the technical details:

  • Base64url encoding - base64 encoding using the URL- and filename-safe character set defined in Section 5 of RFC4648, with all trailing = characters omitted and without the inclusion of any line breaks, whitespace, or other additional characters.

  • Challenge - a random 32 bytes. In JavaScript, it can be created with a call to crypto.getRandomValues(new Uint8Array(32) method. The Challenge should not be a static value. It should be re-generated each time before the relying party sends it to the client. In the Modal UI flow, the Challenge can be associated with the user by one-to-one relation. But the relying party doesn't know anything about the user yet during the Conditional UI flow authentication step. That's why storing challenges separately from users with a nullable user_id field is better.

  • Relying Party (rp) - below, we assume it is a web server.

  • Relying Party Identifier (rp.id) - it is a caller's origin's effective domain.

  • User Name - while this field is called name, it seems that there are no restrictions on using an email instead of a name. There must be an advantage for the Modal UI flow if this string is unique for each user, because this string is used to select all the public key IDs from the storage to set the allowCredentials authentication parameter.

  • User Handle - specified by the Relying Party, as the value of the user.id key. A user handle is an opaque byte sequence with a maximum size of 64 bytes and is not meant to be displayed to the user.

  • Assertion - a signed statement returned by the authenticator carrying data sent by the Relying Party and the client.

Registration

sequenceDiagram
	Client->>Server: generate credential options for the user
	Server->>Client: options (challenge, user.id, rp.id, ...)
	Client->>Authenticator: navigator.credentials.create(options)
	Authenticator->>Client: credential (id, clientDataJSON, publicKey, authenticatorData, ...)
	Client->>Server: validate and save credential for user
	

First, options should be generated on the server side in response to the client's request. The browser API expects some of these options parameters like challenge or user.id to have the BufferSource format. The options can only be passed to the client if they are compatible with JSON format, so they are encoded to a base64url string:

const challenge = crypto.getRandomValues(new Uint8Array(32));
const options = {
  challenge: Base64URL.fromBuffer(challenge),
  rp: {
    id: 'example.com', // effective domain
    name: 'Example Inc',
  },
  user: {
    id: 'some_user_id', // e.g. crypto.randomUUID()
    name: 'username_or_email',
    displayName: 'username_or_email',
  },
  pubKeyCredParams: [
    { type: 'public-key', alg: -7 }, // ES256
    { type: 'public-key', alg: -257 }, // RS256
  ],
  authenticatorSelection: {
    userVerification: 'required', // or preferred
    authenticatorAttachment: 'platform',
  },
  attestation: 'none',
  timeout: 60000,
};

// attach to options created user's credential ids to avoid duplicates:
if (user.credentials) {
  options.excludeCredentials = user.credentials.map((id) => ({
    type: 'public-key',
    id,
  }));
}

On the client side, some values should be decoded back to binary format and passed as options to navigator.credentials.create method:

const options = {
  challenge: Base64URL.toBuffer(json.challenge),
  rp: json.rp,
  user: {
    id: UTF8.toBuffer(json.user.id),
    name: json.user.name,
    displayName: json.user.displayName,
  },
  pubKeyCredParams: json.pubKeyCredParams,
  authenticatorSelection: json.authenticatorSelection,
  attestation: json.attestation,
  timeout: json.timeout,
};

if (json.excludeCredentials) {
  options.excludeCredentials = json.excludeCredentials.map((credential) => ({
    ...credential,
    id: Base64URL.toBuffer(credential.id),
  }));
}

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

Convert the created credential to JSON format and send it to the server:

const attestationResponse = /** @type {AuthenticatorAssertionResponse} */ (credential.response);
const json = {
  credentialId: credential.id,
  credentialType: credential.type,
  clientDataJSON: Base64URL.fromBuffer(attestationResponse.clientDataJSON),
  authenticatorData: Base64URL.fromBuffer(attestationResponse.authenticatorData),
  signature: Base64URL.fromBuffer(attestationResponse.signature),
};

if (attestationResponse.userHandle) {
  json.userHandle = Base64URL.fromBuffer(attestationResponse.userHandle);
}

return json;

On the server side credential should be validated and saved to storage:

if (!attestationJSON.credentialId) {
  throw Error('Attestation must contain credential id');
}

if (attestationJSON.credentialType !== 'public-key') {
  throw Error('Credential type must be public-key');
}

// parse client data
const clientDataBuffer = Base64URL.toBuffer(attestationJSON.clientDataJSON);
const clientDataJSON = JSON.parse(UTF8.fromBuffer(clientDataBuffer));

// validate type
if (clientDataJSON.type !== 'webauthn.create') {
  throw Error('Response type must be webauthn.create');
}

// validate challenge
if (clientDataJSON.challenge !== expectedChallenge) {
  throw Error('Response challenge does not match the expected');
}

// validate origin
if (!expectedOrigin(clientDataJSON.origin)) {
  throw Error('Response origin does not match the expected');
}

// algorithm should be one of those passed to publicKey creation options
if (![-7, -256].includes(attestationJSON.algorithm)) {
  throw Error('Algorithm code must be one of: -7, -257');
}

// parse authenticatorData
const authDataBuffer = Base64URL.toBuffer(attestationJSON.authenticatorData);
const authData = parseAuthenticatorData(authDataBuffer);

// validate relying part id
const authDataRpIdHex = SHA256.toHex(authData.rpId);
const expectedRpIdHash = await SHA256.fromString(expectedRpId);
const expectedRpIdHex = SHA256.toHex(expectedRpIdHash);
if (expectedRpIdHex !== authDataRpIdHex) {
  throw Error('Relying party id does not match the expected');
}

// expect user presence flag
if (!authData.flags.up) {
  throw Error('User presence flag expected to be true');
}

// expect user verified flag
if (!authData.flags.uv) {
  throw Error('User verified flag expected to be true');
}

// object to save in storage along with user.id
return {
  id: attestationJSON.credentialId,
  publicKey: attestationJSON.publicKey,
  algorithm: attestationJSON.algorithm,
  counter: authData.counter,
};

The authenticator (for example, browser) increments the counter for each successful getCredential operation by some positive value. A Relying Party stores the signature counter of the most recent getCredential operation. If either counter value is non-zero, and the new counter value is less than or equal to the stored value, a cloned authenticator may exist, or the authenticator may be malfunctioning.

Authentication

Modal and Conditional UI flows are slightly different. In the Modal UI flow the Relying Party must know which user is trying to log in, in the Conditional UI flow the Relying Party gets the user ID (userHandle) from the authenticator response.

sequenceDiagram
	Client->>Server: generate challenge options for the user
	Server->>Client: options (challenge, allowCredentials, rp.id, ...)
	Client->>Authenticator: navigator.credentials.get(options)
	Authenticator->>Client: credential (id, clientDataJSON, authenticatorData, signature, ...)
	Client->>Server: validate signature, set new counter value

Conditional UI

sequenceDiagram
	Client->>Server: generate random challenge
	Server->>Client: options (challenge, rp.id, ...)
	Client->>Authenticator: navigator.credentials.get(options)
	Authenticator->>Client: credential (userHandle, id, clientDataJSON, authenticatorData, signature, ...)
	Client->>Server: validate signature for the userHandle, set new counter value

Relying party creates and sends the options in JSON format to the client:

const challenge = crypto.getRandomValues(new Uint8Array(32));
const options = {
  challenge: Base64URL.fromBuffer(challenge),
  rpId: settings.rpId,
  userVerification: 'required',
  timeout: 60000,
};

// required for modal ui, optional for conditional ui
if (user.allowCredentialIds) {
  options.allowCredentials = settings.allowCredentialIds.map((id) => ({
    type: 'public-key',
    id,
  }));
}

return options;

The client decodes some option values to ArrayBuffer format and passes them to navigator.credentials.get:

const options = {
  challenge: Base64URL.toBuffer(json.challenge),
  rpId: json.rpId,
  userVerification: json.userVerification,
  timeout: json.timeout,
};

if (json.allowCredentials) {
  options.allowCredentials = json.allowCredentials.map((credential) => ({
    ...credential,
    id: Base64URL.toBuffer(credential.id),
  }));
}

return options;

For the Conditional UI flow, webauthn should be added to autocomplete input attribute:

<input type="email" name="email" placeholder="Your email" autocomplete="username webauthn" />

and mediation option should be set to conditional value:

const credential = await navigator.credentials.get({ publicKey: pkOptions, mediation: "conditional" });

The method navigator.credentials.get should be called as early on the page as possible to fetch all passkeys for the current user and suggest them for autocomplete input.

The client converts the output credential from the navigator.credentials.get method to JSON and passes it to the Relying Party:

const attestationResponse = /** @type {AuthenticatorAssertionResponse} */ (credential.response);
const json = {
  credentialId: credential.id,
  credentialType: credential.type,
  clientDataJSON: Base64URL.fromBuffer(attestationResponse.clientDataJSON),
  authenticatorData: Base64URL.fromBuffer(attestationResponse.authenticatorData),
  signature: Base64URL.fromBuffer(attestationResponse.signature),
};

if (attestationResponse.userHandle) {
  json.userHandle = Base64URL.fromBuffer(attestationResponse.userHandle);
}

return json;

The Relying Party validates the assertion and updates the counter value (if the authenticator supports it, the value should not be equal to 0):

const { expectedChallenge, expectedRpId, expectedOrigin } = options;

if (!assertionJSON.credentialId) {
  throw Error('Attestation must contain credential id');
}

if (assertionJSON.credentialType !== 'public-key') {
  throw Error('Credential type must be public-key');
}

// parse client data
const clientDataBuffer = Base64URL.toBuffer(assertionJSON.clientDataJSON);
const clientDataJSON = JSON.parse(UTF8.fromBuffer(clientDataBuffer));

// validate type
if (clientDataJSON.type !== 'webauthn.get') {
  throw Error('Response type must be webauthn.get');
}

// validate challenge
if (clientDataJSON.challenge !== expectedChallenge) {
  throw Error('Response challenge does not match the expected');
}

// validate origin
if (!expectedOrigin(clientDataJSON.origin)) {
  throw Error('Response origin does not match the expected');
}

// parse authenticatorData
const authDataBuffer = Base64URL.toBuffer(assertionJSON.authenticatorData);
const authData = parseAuthenticatorData(authDataBuffer);

// validate relying part id
const authDataRpIdHex = SHA256.toHex(authData.rpId);
const expectedRpIdHash = await SHA256.fromString(expectedRpId);
const expectedRpIdHex = SHA256.toHex(expectedRpIdHash);
if (expectedRpIdHex !== authDataRpIdHex) {
  throw Error('Relying party id does not match the expected');
}

// expect user presence flag
if (!authData.flags.up) {
  throw Error('User presence flag expected to be true');
}

// expect user verified flag
if (!authData.flags.uv) {
  throw Error('User verified flag expected to be true');
}

// validate counter
if ((credential.counter > 0 || authData.counter > 0)) {
  if (authData.counter <= credential.counter) {
    throw Error('Invalid counter');
  }
}

// validate signature
const clientDataHash = await SHA256.fromBuffer(clientDataBuffer);
const concatenatedBuffer = concatenateBuffers(authDataBuffer, clientDataHash);
const publicKey = Base64URL.toBuffer(credential.publicKey);
const algorithm = SHA256.identify(Number(credential.algorithm));
let signature = Base64URL.toBuffer(assertionJSON.signature);

if (algorithm.name === 'ECDSA') {
  signature = SHA256.der2raw(signature);
}

const cryptoKey = await crypto.subtle.importKey('spki', publicKey, algorithm, false, ['verify']);
const verified = await crypto.subtle.verify(algorithm, cryptoKey, signature, concatenatedBuffer);

return {
  credentialId: credential.id,
  verified,
  counter: authData.counter,
};

A prototype for this project is available from this link. It implements the full WebAuthn Client / Server working example with Deno and SQLite.

Conclusion

Passkeys are a reliable and convenient solution to get rid of passwords. They can work as the second step of two-factor authentication or independently with a password as a fallback authentication method. Sharing credentials via iCloud provides the same advantages as a keychain with a password currently has. With passkeys, users can safely be logged in on someone else's device without the risk of password spying. Unlike many previous attempts to make authorization easier, passkeys look very promising.

Potential integrations

Any user authentication flow:

  • MacPaw Accounts
  • MacPaw Accounts CRM
  • Setapp
  • CleanMyMac SDK

Code

This is an independent publication and it has not been authorized, sponsored, or otherwise approved by Apple Inc.

More From research

Subscribe to our newsletter