WebAuthn Conditional UI is a feature that allows websites to customize the user interface based on the availability of supported authenticators, enhancing the passwordless login experience. This means that if a user has a compatible device or security key, the website can offer a passwordless login option directly, improving usability and security.

What is WebAuthn?

Web Authentication (WebAuthn) is a web standard that enables strong, phishing-resistant authentication using public key cryptography. It allows users to log in to websites using devices such as smartphones, security keys, or built-in biometric sensors without needing to remember passwords.

What is WebAuthn Conditional UI?

WebAuthn Conditional UI is a feature that allows websites to customize the user interface based on the availability of supported authenticators. By detecting whether a user has a compatible device or security key, the website can offer a passwordless login option directly, streamlining the login process.

How does WebAuthn Conditional UI work?

WebAuthn Conditional UI works by leveraging the WebAuthn API to detect the presence of supported authenticators. The website can then conditionally render UI elements based on whether these authenticators are available. This ensures that users are presented with the most appropriate login options.

Why use WebAuthn Conditional UI?

Using WebAuthn Conditional UI enhances the user experience by offering passwordless login options when possible. It also improves security by encouraging the use of strong, phishing-resistant authentication methods.

Implementing WebAuthn Conditional UI

To implement WebAuthn Conditional UI, follow these steps:

Step 1: Check for WebAuthn Support

First, ensure that the browser supports WebAuthn. You can do this by checking for the presence of the PublicKeyCredential object.

if (!window.PublicKeyCredential) {
  console.log('WebAuthn is not supported in this browser.');
} else {
  console.log('WebAuthn is supported.');
}

Step 2: Detect Available Authenticators

Next, use the navigator.credentials.get method with the PublicKeyCredentialRequestOptions to detect available authenticators. This method returns a promise that resolves with a PublicKeyCredential object if an authenticator is available.

const publicKey = {
  challenge: new Uint8Array([/* challenge bytes */]),
  allowCredentials: [
    {
      id: new Uint8Array([/* credential ID bytes */]),
      type: 'public-key',
      transports: ['usb', 'ble', 'nfc', 'internal']
    }
  ],
  userVerification: 'preferred'
};

navigator.credentials.get({ publicKey })
  .then((credential) => {
    console.log('Authenticator available:', credential);
  })
  .catch((error) => {
    console.log('No authenticator available:', error);
  });

Step 3: Conditionally Render UI Elements

Based on the result of the navigator.credentials.get method, conditionally render UI elements. If an authenticator is available, show the passwordless login option. Otherwise, show the traditional password-based login form.

<div id="login-form">
  <!-- Traditional password-based login form -->
  <form id="password-form">
    <label for="username">Username:</label>
    <input type="text" id="username" name="username" required>
    <label for="password">Password:</label>
    <input type="password" id="password" name="password" required>
    <button type="submit">Login</button>
  </form>
  
  <!-- Passwordless login option -->
  <div id="passwordless-option" style="display: none;">
    <button id="passwordless-login">Login with Security Key</button>
  </div>
</div>

<script>
  const publicKey = {
    challenge: new Uint8Array([/* challenge bytes */]),
    allowCredentials: [
      {
        id: new Uint8Array([/* credential ID bytes */]),
        type: 'public-key',
        transports: ['usb', 'ble', 'nfc', 'internal']
      }
    ],
    userVerification: 'preferred'
  };

  navigator.credentials.get({ publicKey })
    .then((credential) => {
      document.getElementById('password-form').style.display = 'none';
      document.getElementById('passwordless-option').style.display = 'block';
    })
    .catch((error) => {
      console.log('No authenticator available:', error);
    });
</script>

Step 4: Handle Passwordless Login

When the user clicks the passwordless login button, initiate the WebAuthn authentication process.

document.getElementById('passwordless-login').addEventListener('click', () => {
  const publicKey = {
    challenge: new Uint8Array([/* challenge bytes */]),
    allowCredentials: [
      {
        id: new Uint8Array([/* credential ID bytes */]),
        type: 'public-key',
        transports: ['usb', 'ble', 'nfc', 'internal']
      }
    ],
    userVerification: 'preferred'
  };

  navigator.credentials.get({ publicKey })
    .then((credential) => {
      // Send the assertion response to the server for verification
      console.log('Assertion response:', credential.response);
    })
    .catch((error) => {
      console.log('Authentication failed:', error);
    });
});

Common Pitfalls and Solutions

Pitfall: Incorrect Challenge Generation

Generating an incorrect challenge can lead to authentication failures. Ensure that the challenge is a random byte array generated on the server and sent to the client.

// Incorrect challenge generation
const incorrectChallenge = 'not-random-enough';

// Correct challenge generation
const correctChallenge = crypto.getRandomValues(new Uint8Array(32));

Pitfall: Missing Allow Credentials

Failing to include the allowCredentials array can prevent the browser from detecting available authenticators.

// Missing allowCredentials
const missingAllowCredentials = {
  challenge: new Uint8Array([/* challenge bytes */]),
  userVerification: 'preferred'
};

// Correct allowCredentials
const correctAllowCredentials = {
  challenge: new Uint8Array([/* challenge bytes */]),
  allowCredentials: [
    {
      id: new Uint8Array([/* credential ID bytes */]),
      type: 'public-key',
      transports: ['usb', 'ble', 'nfc', 'internal']
    }
  ],
  userVerification: 'preferred'
};

Pitfall: Improper Error Handling

Improper error handling can lead to a poor user experience. Ensure that errors are caught and handled gracefully.

// Improper error handling
navigator.credentials.get({ publicKey })
  .then((credential) => {
    console.log('Authenticator available:', credential);
  });

// Proper error handling
navigator.credentials.get({ publicKey })
  .then((credential) => {
    console.log('Authenticator available:', credential);
  })
  .catch((error) => {
    console.log('No authenticator available:', error);
    alert('No compatible authenticator found. Please try another login method.');
  });

Security Considerations

Protect Against Bypass Attacks

Ensure that the conditional rendering logic does not expose sensitive information that could be used to bypass authentication.

// Vulnerable to bypass attack
if (navigator.credentials.get({ publicKey })) {
  document.getElementById('passwordless-option').style.display = 'block';
}

// Secure against bypass attack
navigator.credentials.get({ publicKey })
  .then((credential) => {
    document.getElementById('passwordless-option').style.display = 'block';
  })
  .catch((error) => {
    console.log('No authenticator available:', error);
  });

Validate Server-Side

Always validate the assertion response on the server-side to prevent replay attacks and other forms of fraud.

// Client-side validation only
if (credential.response.userHandle === expectedUserHandle) {
  console.log('User verified');
}

// Server-side validation
fetch('/verify-assertion', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    id: credential.id,
    rawId: Array.from(new Uint8Array(credential.rawId)),
    type: credential.type,
    response: {
      authenticatorData: Array.from(new Uint8Array(credential.response.authenticatorData)),
      clientDataJSON: Array.from(new Uint8Array(credential.response.clientDataJSON)),
      signature: Array.from(new Uint8Array(credential.response.signature)),
      userHandle: Array.from(new Uint8Array(credential.response.userHandle))
    }
  })
})
.then(response => response.json())
.then(data => {
  if (data.success) {
    console.log('User verified');
  } else {
    console.log('Verification failed');
  }
})
.catch(error => {
  console.log('Error verifying assertion:', error);
});

Comparison of Approaches

ApproachProsConsUse When
Traditional Password LoginSimple to implementVulnerable to phishing attacksLegacy systems
Passwordless Login with WebAuthnStrong, phishing-resistant authenticationRequires user to have compatible deviceNew systems

Quick Reference

📋 Quick Reference

  • navigator.credentials.get({ publicKey }) - Initiates the WebAuthn authentication process.
  • crypto.getRandomValues(new Uint8Array(32)) - Generates a random 32-byte challenge.
  • fetch('/verify-assertion', { method: 'POST', body: JSON.stringify(assertionResponse) }) - Sends the assertion response to the server for verification.

Best Practices

💜 Pro Tip: Always validate the assertion response on the server-side.

🎯 Key Takeaways

  • Check for WebAuthn support using `window.PublicKeyCredential`.
  • Detect available authenticators using `navigator.credentials.get`.
  • Conditionally render UI elements based on authenticator availability.
  • Protect against bypass attacks by validating server-side.

Conclusion

Implementing WebAuthn Conditional UI can significantly enhance the user experience and security of your website. By detecting available authenticators and conditionally rendering UI elements, you can offer a seamless passwordless login experience. Remember to validate the assertion response on the server-side and protect against bypass attacks to ensure a secure implementation.

That’s it. Simple, secure, works. Go build it!