MasterKey2 SDK

A TypeScript SDK for integrating WebAuthn passkey authentication into web applications. Provides three integration approaches: a JavaScript API, declarative Web Components, and an event-driven architecture.

Table of Contents


Installation

<script type="module">
import MasterKey2 from 'https://your-auth-server.com/sdk/masterkey2.js';

const auth = new MasterKey2({
  apiBaseUrl: 'https://your-auth-server.com'
});
</script>

Self-Hosted

Download masterkey2.js from your bv-masterkey2 server at /sdk/masterkey2.js and serve it from your own static directory.

Web Components Only

Web components auto-register when the SDK script loads. No additional setup is needed — just import the script and use the HTML elements:

<script type="module" src="https://your-auth-server.com/sdk/masterkey2.js"></script>
<masterkey2-authenticate
  api-base-url="https://your-auth-server.com"
  token="<session-token-from-server>">
</masterkey2-authenticate>

Quick Start

import MasterKey2, { MK2Events } from './masterkey2.js';

const auth = new MasterKey2({
  apiBaseUrl: 'https://auth.example.com',
});

// Passkey login
await auth.passkey.authenticate({
  onSuccess: (result) => {
    window.location.href = result.redirectUrl || '/dashboard';
  },
  onError: (error) => {
    console.error('Login failed:', error.message);
  }
});

Configuration

interface MasterKey2Config {
  apiBaseUrl: string;         // Required. Base URL of bv-masterkey2 server.
  debug?: boolean;            // Default: false. Enable console logging.
  timeout?: number;           // Default: 30000. API request timeout (ms). Enforced via AbortController.
  qrPollInterval?: number;    // Default: 2000. HTTP polling interval for QR status (ms).
  qrExpirationTime?: number;  // Default: 300000. Client-side QR expiration (ms, 5 min).
  token?: string;             // Registration token for cross-origin passkey registration.
}
OptionDefaultDescription
apiBaseUrlRequired. bv-masterkey2 server URL (e.g. https://auth.example.com). Trailing slashes are stripped.
debugfalseLogs SDK activity to console as [MasterKey2 SDK] ...
timeout30000Request timeout in milliseconds. Enforced via AbortController — requests that take longer are aborted and surface as server_unreachable.
qrPollInterval2000How often to poll for QR status when WebSocket is unavailable (ms)
qrExpirationTime300000Client-side QR code expiration. QR auto-refreshes 1 minute before this.
tokenRegistration token (from POST /api/v1/user-token) or tenant auth token (from POST /api/v1/session-token). Sent as Authorization: Bearer header. See Cross-Origin Registration.

JavaScript API

MasterKey2

The main SDK class. All modules are accessible as properties.

const auth = new MasterKey2(config);

auth.passkey       // PasskeyModule
auth.qr            // QRModule
auth.password      // PasswordModule
auth.management    // ManagementModule

auth.on(event, handler)    // Add event listener
auth.off(event, handler)   // Remove event listener
auth.once(event, handler)  // Add one-time event listener
auth.emit(event, detail)   // Emit event (for advanced use)
auth.getApiBaseUrl()       // Returns configured API base URL
auth.getConfig()           // Returns the internal Config object

Passkey Module

auth.passkey.authenticate(options?)

Authenticate using a device passkey (fingerprint, face recognition, security key).

Flow: POST /auth/v1/authenticate/startnavigator.credentials.get()POST /auth/v1/authenticate/finish

Parameters:

interface PasskeyAuthenticateOptions {
  /** Combined PRF salt (base64url). When provided, the PRF extension is injected
   *  into the WebAuthn ceremony and the PRF output is included in success events
   *  and callbacks. The salt is never sent to the server. */
  prfSalt?: string;
  onSuccess?: (result: AuthResponse & { prfOutput?: string, capabilities?: { prf: boolean } }) => void;
  onError?: (error: MK2Error) => void;
}

When prfSalt is provided, the success result includes:

  • prfOutput — base64url-encoded PRF output from the authenticator (or undefined if the authenticator doesn’t support PRF)
  • capabilities.prftrue if the authenticator produced a PRF output, false otherwise

Returns: Promise<AuthResponse>

Events emitted: PASSKEY_STARTPASSKEY_SUCCESS + AUTH_SUCCESS (on success) or PASSKEY_ERROR + AUTH_ERROR (on failure)

Example:

const result = await auth.passkey.authenticate({
  onSuccess: (result) => {
    console.log('Authenticated as:', result.user.externalId);
    window.location.href = result.redirectUrl || '/';
  },
  onError: (error) => {
    if (error.code === 'user_cancelled') return; // User dismissed dialog
    console.error(error.code, error.message);
  }
});

auth.passkey.register(options?)

Register a new passkey for the current user. Requires either an authenticated bv-masterkey2 session (same-origin) or a registration token (cross-origin).

Flow: POST /auth/v1/register/startnavigator.credentials.create()POST /auth/v1/register/finish

Parameters:

interface PasskeyRegisterOptions {
  name?: string;                   // Required. Display name for the passkey.
  authenticatorAttachment?:        // Optional. Override authenticator type.
    | 'platform'                   //   Local (Touch ID, Windows Hello, biometrics)
    | 'cross-platform'             //   External (security key, phone via QR)
    | undefined;                   //   Let the browser offer all options (default)
  /** PRF mode for registration. When set, requests a PRF salt from the server
   *  and evaluates the PRF extension during credential creation.
   *  - "event":    PRF output included in success callback/event
   *  - "callback": PRF output POSTed to prfCallback URL; hash commitment sent to server
   *  - "both":     PRF output in event AND POSTed to prfCallback */
  prf?: 'event' | 'callback' | 'both';
  /** URL to POST PRF output to (required when prf="callback" or prf="both").
   *  The SDK POSTs { challengeId, prfOutput, capabilities: { prf: boolean } }
   *  with credentials: 'include' (sends cookies). */
  prfCallback?: string;
  onSuccess?: (result: AuthResponse & { prfOutput?: string, capabilities?: { prf: boolean } }) => void;
  onError?: (error: MK2Error) => void;
}

When prf is set:

  • The SDK requests a deterministic PRF salt from the server during register/start (prf: true in the request body)
  • The PRF extension is evaluated during navigator.credentials.create()
  • A SHA-256 hash commitment (SHA-256(challengeId + ':' + prfOutput)) is sent to register/finish as prfHash — the raw PRF output is never sent to the server
  • The success result includes capabilities.prf (true if the authenticator supports PRF)
  • In "event" or "both" mode, prfOutput (base64url) is included in the success result
  • In "callback" or "both" mode, the SDK POSTs to prfCallback

Returns: Promise<AuthResponse>

Events emitted: PASSKEY_ADDED (on success, includes prfEnabled) or PASSKEY_ERROR (on failure)

Example:

await auth.passkey.register({
  name: 'My MacBook',
  authenticatorAttachment: 'cross-platform',
  onSuccess: () => console.log('Passkey registered!'),
});

Note: If authenticatorAttachment is also set by the server in the challenge options, the server value takes precedence.


QR Module (Cross-Device)

For browsers without native cross-device WebAuthn support (Firefox), or when the user needs to authenticate/register using a different device.

Status updates use WebSocket for real-time feedback with automatic fallback to HTTP polling. The WebSocket connection times out after 2 minutes and switches to polling to conserve server resources. Transient network errors during polling (server unreachable, rate limited) are retried up to 3 consecutive times within a polling session. If all 3 fail, the polling session restarts on the same challenge after a 3-second delay (up to 3 restarts, tolerating 12 total failures). The QR stays visible, scanned state is preserved, and in-progress mobile authentication is not disrupted.

auth.qr.startAuth(options?)

Start cross-device authentication with a QR code.

Parameters:

interface QRAuthOptions {
  onQRGenerated?: (qrUrl: string, challengeId: string) => void;
  onStatusChange?: (status: string, message?: string) => void;
  onSuccess?: (redirectUrl?: string, user?: { id?: string; externalId?: string; displayName?: string }) => void;
  onError?: (error: MK2Error) => void;
  onExpired?: () => void;
}

Returns: Promise<QRSession>

interface QRSession {
  challengeId: string;     // Challenge ID for this session
  qrUrl: string;           // URL of the QR code image
  cancel: () => void;      // Stop watching for status updates
  refresh: () => Promise<void>;  // Generate a new QR code
}

Status values: pendingscannedcompleted | expired | cancelled | failed | error

Events emitted: QR_GENERATED, QR_STATUS_CHANGE, QR_SUCCESS + AUTH_SUCCESS (on complete), QR_ERROR + AUTH_ERROR (on failure), QR_EXPIRED (on timeout)

Auto-refresh: The QR code automatically refreshes 1 minute before qrExpirationTime unless the QR has already been scanned (auth is in-flight).

Example:

const session = await auth.qr.startAuth({
  onQRGenerated: (qrUrl) => {
    document.getElementById('qr').src = qrUrl;
  },
  onStatusChange: (status) => {
    if (status === 'scanned') {
      document.getElementById('hint').textContent = 'Complete authentication on your phone...';
    }
  },
  onSuccess: (redirectUrl) => {
    window.location.href = redirectUrl || '/';
  },
  onExpired: () => {
    console.log('QR expired, refreshing...');
    session.refresh();
  }
});

// Cancel manually
document.getElementById('cancel-btn').onclick = () => session.cancel();

auth.qr.startRegister(options?)

Start cross-device registration with a QR code. Same flow as startAuth but creates a passkey instead.

Parameters:

interface QRRegisterOptions {
  name?: string;            // Required. Display name for the passkey.
  onQRGenerated?: (qrUrl: string, challengeId: string) => void;
  onStatusChange?: (status: string, message?: string) => void;
  onSuccess?: (redirectUrl?: string) => void;
  onError?: (error: MK2Error) => void;
  onExpired?: () => void;
}

Returns: Promise<QRSession>


Password Module

auth.password.login(options)

Authenticate with username and password.

Parameters:

interface PasswordLoginOptions {
  username: string;
  password: string;
  onSuccess?: (result: AuthResponse) => void;
  onError?: (error: Error) => void;
}

Returns: Promise<AuthResponse>

Events emitted: AUTH_SUCCESS (on success) or AUTH_ERROR (on failure)


Management Module

Requires an authenticated session.

auth.management.list()

List all passkeys for the current user.

Returns: Promise<PasskeyInfo[]>

interface PasskeyInfo {
  id: string;
  name: string;
  created_at: string;
  last_used?: string;
}

Events emitted: PASSKEYS_LOADED

auth.management.delete(passkeyId)

Delete a passkey by ID.

Returns: Promise<void>

Events emitted: PASSKEY_DELETED


Event System

The SDK provides a typed event system. Every authentication action emits granular events (e.g. PASSKEY_SUCCESS) and global events (e.g. AUTH_SUCCESS), so you can listen at whatever level of detail you need.

Event Constants

import { MK2Events } from './masterkey2.js';

// Passkey events
MK2Events.PASSKEY_START       // 'mk2:passkey:start'
MK2Events.PASSKEY_SUCCESS     // 'mk2:passkey:success'
MK2Events.PASSKEY_ERROR       // 'mk2:passkey:error'

// QR events
MK2Events.QR_GENERATED        // 'mk2:qr:generated'
MK2Events.QR_STATUS_CHANGE    // 'mk2:qr:status-change'
MK2Events.QR_REFRESHED        // 'mk2:qr:refreshed'
MK2Events.QR_SUCCESS          // 'mk2:qr:success'
MK2Events.QR_ERROR            // 'mk2:qr:error'
MK2Events.QR_EXPIRED          // 'mk2:qr:expired'

// Management events
MK2Events.PASSKEY_ADDED       // 'mk2:passkey:added'
MK2Events.PASSKEY_DELETED     // 'mk2:passkey:deleted'
MK2Events.PASSKEYS_LOADED     // 'mk2:passkeys:loaded'

// Global events (fired alongside specific events)
MK2Events.AUTH_SUCCESS         // 'mk2:auth:success'
MK2Events.AUTH_ERROR           // 'mk2:auth:error'

Event Detail Shapes

EventDetail
PASSKEY_START{ timestamp: number }
PASSKEY_SUCCESS{ redirectUrl?: string, user?: UserInfo, prfOutput?: string, capabilities?: { prf: boolean } }
PASSKEY_ERROR{ error: Error | string, code?: string }
QR_GENERATED{ qrUrl: string, challengeId: string }
QR_STATUS_CHANGE{ status: string, message?: string }
QR_REFRESHED{ qrUrl: string, challengeId: string }
QR_SUCCESS{ redirectUrl?: string, user?: UserInfo }
QR_ERROR{ error: Error | string, code?: string }
QR_EXPIRED{ challengeId: string }
PASSKEY_ADDED{ passkeyId: string, prfEnabled: boolean }
PASSKEY_DELETED{ passkeyId: string }
PASSKEYS_LOADED{ count: number }
AUTH_SUCCESS{ redirectUrl?: string, user?: UserInfo, prfOutput?: string, capabilities?: { prf: boolean } }
AUTH_ERROR{ error: Error | string, code?: string }

Where UserInfo is { id: string, externalId: string, displayName?: string }.

Note: prfOutput and capabilities are only present in success events when PRF was requested (via prfSalt on authenticate, or prf on register). prfOutput is base64url-encoded. PASSKEY_ADDED.prfEnabled indicates whether the authenticator reported PRF support during registration.

Listening to Events

import MasterKey2, { MK2Events } from './masterkey2.js';

const auth = new MasterKey2({ apiBaseUrl: '...' });

// Global handler for any successful auth
auth.on(MK2Events.AUTH_SUCCESS, (e) => {
  console.log('User:', e.detail.user);
  window.location.href = e.detail.redirectUrl || '/';
});

// Global error handler
auth.on(MK2Events.AUTH_ERROR, (e) => {
  console.error('Error:', e.detail.error, e.detail.code);
});

// One-time listener
auth.once(MK2Events.PASSKEY_SUCCESS, (e) => {
  console.log('First passkey login:', e.detail.user);
});

// Remove listener
const handler = (e) => console.log(e.detail);
auth.on(MK2Events.QR_GENERATED, handler);
auth.off(MK2Events.QR_GENERATED, handler);

Error Handling

MK2ErrorCode

All errors thrown or emitted by the SDK are MK2Error instances with a semantic code property:

import { MK2ErrorCode, MK2Error } from './masterkey2.js';

MK2ErrorCode.USER_CANCELLED         // 'user_cancelled'      — user dismissed WebAuthn dialog
MK2ErrorCode.SERVER_UNREACHABLE     // 'server_unreachable'  — network failure / fetch error
MK2ErrorCode.CREDENTIAL_NOT_FOUND   // 'credential_not_found' — no matching passkey on server
MK2ErrorCode.CHALLENGE_EXPIRED      // 'challenge_expired'   — QR code or challenge timed out
MK2ErrorCode.WEBAUTHN_NOT_SUPPORTED // 'webauthn_not_supported' — browser lacks WebAuthn
MK2ErrorCode.USER_DISABLED          // 'user_disabled'       — user account has been disabled by tenant
MK2ErrorCode.CONFIGURATION_ERROR    // 'configuration_error' — missing token attribute or tenant rp_id/origin mismatch
MK2ErrorCode.RATE_LIMITED           // 'rate_limited'        — 429 Too Many Requests
MK2ErrorCode.SERVER_ERROR           // 'server_error'        — server returned an error
MK2ErrorCode.UNKNOWN                // 'unknown'             — unclassified error

MK2Error Class

class MK2Error extends Error {
  readonly code: MK2ErrorCodeType;
  retryAfter?: number;  // Seconds until retry (populated on rate_limited errors from Retry-After header)
  constructor(message: string, code: MK2ErrorCodeType);
}

Raw browser errors (e.g. NotAllowedError from WebAuthn) are automatically classified into MK2Error with the appropriate code. You can use the code to drive differentiated UX:

auth.passkey.authenticate({
  onError: (error) => {
    switch (error.code) {
      case 'user_cancelled':
        // User dismissed dialog -- do nothing, reset UI silently
        break;
      case 'server_unreachable':
        showBanner('Cannot reach authentication server. Check your connection.');
        break;
      case 'credential_not_found':
        showBanner('No passkey found. Register one first.');
        break;
      case 'challenge_expired':
        // Silently retry or show refresh prompt
        break;
      case 'user_disabled':
        showBanner('Your account has been suspended. Contact support.');
        break;
      case 'configuration_error':
        // Tenant rp_id doesn't match the page origin (wrong API key?)
        showBanner('Authentication service misconfigured. Contact site admin.');
        break;
      default:
        showBanner('Authentication failed: ' + error.message);
    }
  }
});

Web Components

The SDK includes five web components, auto-registered when the script loads. All use Shadow DOM for style encapsulation.

Composite Components

The composite components handle browser detection, mode selection, and UI rendering automatically. These are the recommended integration path for most applications.

<masterkey2-authenticate>

Auto-detects the browser and renders either a passkey button, a QR code, or an “unavailable” message. See Browser Detection for the decision logic.

Attributes:

AttributeDefaultDescription
api-base-urlRequired. bv-masterkey2 server URL. The component shows a configuration error if missing.
debugEnable console logging (boolean attribute)
silentSuppress internal status messages (boolean attribute)
modeOverride browser detection: 'passkey' | 'qr' | 'qr-desktop' | 'auto'. If omitted or 'auto', detection runs normally. 'passkey' shows an “unsupported” message if WebAuthn is unavailable. 'qr' forces the cross-device QR flow. 'qr-desktop' forces QR on desktop but auto-detects on mobile.
label'Sign in with Passkey'Passkey button text
tokenRequired. Session token identifying the tenant (obtained server-side from POST /api/v1/session-token). The component shows a configuration error if missing.
prfEnable PRF extension: 'event' | 'callback' | 'both'. When set, the iframe requests a deterministic PRF salt from the server and includes the PRF result in finish_authentication. See PRF Extension for details on each mode.
prf-callbackURL to POST PRF output to. Required when prf="callback" or prf="both". The SDK POSTs { challengeId, prfOutput, capabilities: { prf: boolean } } with credentials: 'include'.
theme'auto'Theme: 'auto' | 'dark' | 'light' | 'class'

Events:

EventDetailDescription
readyIframe loaded and initialized
mode-detected{ mode, reason }Fires after browser detection resolves inside the iframe
success{ challengeId, user?, prfOutput?, capabilities? }Authentication completed. prfOutput (base64url) is present when prf="event" or prf="both". capabilities.prf indicates authenticator PRF support. Use challengeId with verify_auth to get user info server-side.
error{ error, code? }Authentication failed
expired{}QR code expired

Architecture: The component creates a cross-origin iframe pointing to /auth-embed on bv-masterkey2. The iframe handles browser detection, passkey/QR authentication, and optional PRF extension internally. Communication with the parent is via postMessage. CSP frame-ancestors restricts embedding to the consuming app’s origin.

Behavior by detected mode (inside iframe):

  • Passkey mode: Renders a button. Click triggers navigator.credentials.get().
  • QR mode: Immediately generates and displays a QR code (no button click needed). When the mobile device scans, the QR image is replaced with a processing spinner.
  • Unavailable mode: Displays a message: “Passkey authentication requires a secure (HTTPS) connection.” or “Passkey authentication is not supported by this browser.” (when mode="passkey" is forced on an incompatible browser).

Example:

<!-- Token fetched server-side, never expose API key to browser -->
<masterkey2-authenticate
  api-base-url="https://auth.example.com"
  token="<session-token-from-server>"
  debug>
</masterkey2-authenticate>

<script type="module">
  const el = document.querySelector('masterkey2-authenticate');

  el.addEventListener('mode-detected', (e) => {
    console.log('Using mode:', e.detail.mode, '—', e.detail.reason);
  });

  el.addEventListener('success', (e) => {
    console.log('Authenticated:', e.detail.user);
    window.location.href = e.detail.redirectUrl || '/';
  });

  el.addEventListener('error', (e) => {
    if (e.detail.code === 'user_cancelled') return;
    console.error('Auth error:', e.detail.error.message);
  });
</script>

<masterkey2-register>

Auto-detects the browser and renders the appropriate registration UI. Requires either an authenticated bv-masterkey2 session (same-origin) or a registration token (cross-origin).

Attributes:

AttributeDefaultDescription
api-base-urlRequired. bv-masterkey2 server URL. The component shows a configuration error if missing.
debugEnable console logging (boolean attribute)
silentSuppress internal status messages (boolean attribute)
mode'auto'Override detection: 'auto' | 'passkey' | 'qr' | 'qr-desktop'. 'passkey' shows an “unsupported” message if WebAuthn is unavailable. 'qr' forces the cross-device QR flow. 'qr-desktop' forces QR on desktop but auto-detects on mobile.
label'Create Passkey'Passkey button text
name''Display name for the passkey being registered
tokenUser token for cross-origin use (pre-fetched at page render). Either token or assertion-url is required — the component shows a configuration error if neither is provided.
assertion-urlURL to fetch a just-in-time user token (see Just-in-Time Registration Tokens). When set, the token is fetched on click. In QR mode the QR code renders in a cross-origin iframe for XSS isolation; in passkey mode (mobile) the token is used as a Bearer token for the registration API calls. Either token or assertion-url is required.
qr-position'below'QR popover placement: 'above' | 'below' | 'left' | 'right'
qr-inlineRender QR immediately without popover (boolean attribute)
prfEnable PRF extension during registration: 'event' | 'callback' | 'both'. When set, the SDK requests a PRF salt from the server, evaluates the PRF extension during credential creation, and sends a hash commitment (never the raw output) to the server. See PRF Extension.
prf-callbackURL to POST PRF output to. Required when prf="callback" or prf="both". The SDK POSTs { challengeId, prfOutput, capabilities: { prf: boolean } } with credentials: 'include'.
theme'auto'Theme: 'auto' | 'dark' | 'light' | 'class'

Events: Same as <masterkey2-authenticate> (mode-detected, success, error, expired). When prf is set, the success event includes prfOutput (in "event" / "both" mode) and capabilities.prf.

Behavior by mode:

  • Passkey mode: Renders a button. Click triggers navigator.credentials.create(). On mobile, authenticatorAttachment is undefined (all authenticator types allowed). On desktop, it’s set to 'cross-platform' to prompt the user to use their phone.
  • QR mode: Renders a button (“Create Passkey via QR Code”). Click opens a popover containing the QR code. The popover is dismissible by clicking outside, pressing Escape, or clicking the button again. If the mobile user cancels, a fresh QR is automatically regenerated.
  • QR-desktop mode: Forces QR mode on desktop browsers; on mobile devices, falls back to auto-detection (passkey with biometrics on secure contexts, unavailable on insecure).
  • Unavailable mode: Displays a message: “Passkey registration requires a secure (HTTPS) connection.” or “Passkey registration is not supported by this browser.” (when mode="passkey" is forced on an incompatible browser).

Example (cross-origin with token):

<!-- Token fetched server-side, never expose API key to browser -->
<masterkey2-register
  api-base-url="https://auth.example.com"
  token="<registration-token-from-server>"
  name="My Passkey">
</masterkey2-register>

<script type="module">
  const el = document.querySelector('masterkey2-register');

  el.addEventListener('success', (e) => {
    console.log('Passkey registered!');
  });
</script>

Primitive Components

For fine-grained control when you need to compose your own UI rather than using the auto-detecting composite components.

<masterkey2-passkey>

A passkey login button.

Attributes:

AttributeDefaultDescription
api-base-urlRequired. bv-masterkey2 server URL
debugEnable console logging
silentSuppress internal status messages
label'🔐 Sign in with Passkey'Button text
button-class'primary-button'CSS class for the button

Events:

EventDetail
success{ redirectUrl?, user? }
error{ error, code? }

Example:

<masterkey2-passkey
  api-base-url="https://auth.example.com"
  label="Sign in">
</masterkey2-passkey>

<script>
  document.querySelector('masterkey2-passkey')
    .addEventListener('success', (e) => {
      window.location.href = e.detail.redirectUrl || '/';
    });
</script>

<masterkey2-qr>

QR code authentication component with optional auto-start and controls.

Attributes:

AttributeDefaultDescription
api-base-urlRequired. bv-masterkey2 server URL
debugEnable console logging
silentSuppress internal status messages
auto-startAuto-start QR flow on mount (boolean attribute)
show-controlstrueShow Refresh/Cancel buttons

Events:

EventDetail
qr-generated{ qrUrl, challengeId }
status-change{ status, message? }
success{ redirectUrl? }
error{ error, code? }
expired{}
refreshed{}
cancelled{}

Public methods:

const qr = document.querySelector('masterkey2-qr');
await qr.startAuth();   // Start the QR flow
await qr.refresh();     // Generate a new QR code
qr.cancel();            // Stop watching and reset

<masterkey2-status>

A status indicator that shows loading, success, or error states.

Attributes:

AttributeDefaultDescription
status'idle'Current state: 'idle' | 'loading' | 'success' | 'error'
message''Status text to display

Events:

EventDetail
status-change{ status, message }

Public methods:

const status = document.querySelector('masterkey2-status');
status.setStatus('loading', 'Authenticating...');
status.setStatus('success', 'Done!');
status.setStatus('error', 'Something went wrong');
status.clear();  // Reset to idle (hidden)

Cross-Origin Registration

When <masterkey2-register> (or auth.passkey.register()) is used on a different origin from bv-masterkey2, session cookies won’t work due to SameSite=Lax. The SDK supports a registration token flow as an alternative.

Prerequisites

You need a tenant (with an API key) before using the SDK. Create one via:

  • Admin dashboard at GET /admin (log in at GET / with an admin email)
  • CLI: cargo run -p bv-masterkey2 --bin bv-masterkey2-cli -- tenant add "Tenant Name"

The API key is shown once on creation — save it immediately.

Approach A: Pre-fetched Token (simple, short-lived pages)

  1. Your server calls POST /api/v1/user-token with the tenant API key:

    POST /api/v1/user-token
    Headers: X-API-KEY: <tenant-api-key>
    Body: { "externalId": "user@example.com", "displayName": "Alice", "ttl": 600 }
    Response: { "userToken": "...", "userId": "..." }

    The ttl field is optional (seconds, clamped to 5..=600, default 600).

  2. Pass the token to the client (as an HTML attribute or JS config):

    <masterkey2-register
      api-base-url="https://auth.example.com"
      token="<token>"
      name="My Passkey">
    </masterkey2-register>

    Or via the JS API:

    const auth = new MasterKey2({
      apiBaseUrl: 'https://auth.example.com',
      token: '<token>'
    });
    await auth.passkey.register({ name: 'My Passkey' });
  3. The SDK sends Authorization: Bearer <token> on registration API calls instead of relying on cookies.

For long-lived pages where a pre-fetched token might expire before the user clicks “Register”, use the assertion-url attribute instead of token:

  1. Create a same-origin endpoint on your server that validates the user’s session and fetches a short-lived token:

    // e.g., GET /api/mk2-user-token
    export async function GET({ cookies }) {
      const session = validateSession(cookies);
      const res = await fetch(`${MASTERKEY2_URL}/api/v1/user-token`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'X-API-KEY': MK2_API_KEY },
        body: JSON.stringify({ externalId: session.email, displayName: session.email, ttl: 30 }),
      });
      const data = await res.json();
      return Response.json({ userToken: data.userToken });
    }
  2. Pass the endpoint URL to the component:

    <masterkey2-register
      api-base-url="https://auth.example.com"
      assertion-url="/api/mk2-user-token"
      name="My Passkey">
    </masterkey2-register>
  3. When the user clicks “Register”, the component fetches a fresh token from your endpoint, then renders the QR code inside a cross-origin iframe on bv-masterkey2 (/register-embed). The QR URL never enters the consuming app’s DOM — it stays isolated inside the iframe.

Why this is better for dashboards:

  • No token fetched at page load (nothing to expire)
  • Token has a very short TTL (e.g., 30 seconds) since it’s fetched right before use
  • The QR code URL is isolated inside a cross-origin iframe — not accessible to XSS in the consuming app

Security

  • The tenant API key never reaches the browser. Your server calls the token endpoint server-side.
  • Tokens are short-lived (configurable TTL, default 10 min, minimum 5 seconds), scoped to a single user, and stored in-memory on the bv-masterkey2 server.
  • A token can only be used for passkey registration for the specific user it was created for.
  • With assertion-url, the QR code URL is confined to a cross-origin iframe, preventing XSS exfiltration from the consuming app’s JS context.

Just-in-Time Registration Tokens

See Approach B above. The assertion-url attribute on <masterkey2-register> enables this flow. When the user clicks “Register”, the component:

  1. fetch(assertionUrl, { credentials: 'include' }) — sends the user’s session cookie to your endpoint
  2. Your endpoint returns { userToken: "..." }

In QR mode (desktop):

  1. The component creates an iframe pointing to ${apiBaseUrl}/register-embed
  2. On iframe load, sends postMessage({ type: 'init', token, name }) to the iframe
  3. The iframe (running on bv-masterkey2’s origin) generates the QR code and watches for status
  4. Status events (scanned, success, error, expired, cancelled) are relayed back to the parent via postMessage

The /register-embed page is a standalone HTML page on bv-masterkey2. The SDK appends ?origin=<parent-origin> to the iframe URL, and the server sets Content-Security-Policy: frame-ancestors <origin> — restricting which sites can embed the iframe. The page imports startCrossDeviceRegister and watchChallengeStatus from the SDK.

In passkey mode (mobile):

  1. The token is sent as Authorization: Bearer on the registration API calls (begin_registration, finish_registration)
  2. No iframe is used — the browser’s native WebAuthn dialog handles the flow directly

When bv-masterkey2 runs on a different domain from your app (e.g., your app is myapp.com and bv-masterkey2 is auth.bankvault.com), cross-device QR flows require an extra setup step. The passkey is bound to your app’s domain (the RP ID), but the mobile device opens a page on bv-masterkey2’s domain. Browsers need to verify that bv-masterkey2 is authorized to use your domain as an RP ID.

This is handled by the W3C Related Origin Requests spec. Your app serves a JSON file that declares bv-masterkey2 as an allowed origin.

Setup

Serve this endpoint from your app (the RP ID’s domain):

GET https://myapp.com/.well-known/webauthn
Content-Type: application/json

{ "origins": ["https://auth.bankvault.com"] }

The origins array should contain your bv-masterkey2 server’s origin (scheme + host + port if non-standard).

That’s it. No changes needed on bv-masterkey2 — the browser handles everything automatically.

How it works

  1. Desktop Firefox on myapp.com starts a cross-device QR flow
  2. Mobile Safari/Chrome scans the QR code, opens auth.bankvault.com
  3. bv-masterkey2 returns rpId: 'myapp.com' (from the tenant’s rp_id setting)
  4. The mobile browser calls navigator.credentials.get({ rpId: 'myapp.com' })
  5. Because myapp.comauth.bankvault.com, the browser fetches https://myapp.com/.well-known/webauthn
  6. It finds auth.bankvault.com in the origins array — WebAuthn ceremony proceeds

When is this needed?

ScenarioROR needed?
Direct passkey login on myapp.comNo — RP ID matches the page domain
Cross-device QR → mobile opens bv-masterkey2 on a different domainYes
Same domain (e.g., same host, different ports in dev)No

Browser support

BrowserROR Support
Chrome 128+ (July 2024)Yes
Safari 18+ / iOS 18+ (Sep 2024)Yes
FirefoxNo — but Firefox users only need ROR on the mobile device (Safari/Chrome) that scans the QR

Example (Astro)

// src/pages/.well-known/webauthn.ts
import type { APIRoute } from 'astro';

const MASTERKEY2_URL = process.env.MASTERKEY2_URL || import.meta.env.MASTERKEY2_URL;

export const GET: APIRoute = () => {
  return new Response(
    JSON.stringify({ origins: [MASTERKEY2_URL] }),
    { headers: { 'Content-Type': 'application/json' } }
  );
};

Example (Express)

app.get('/.well-known/webauthn', (req, res) => {
  res.json({ origins: [process.env.MASTERKEY2_URL] });
});

Example (static file)

If your bv-masterkey2 URL doesn’t change, serve a static .well-known/webauthn file:

{ "origins": ["https://auth.bankvault.com"] }

PRF Extension (Extracting Encryption Keys from Passkeys)

The WebAuthn PRF extension allows authenticators to derive a deterministic secret during authentication. This secret can be used as key material for client-side encryption (e.g., encrypting user data in the browser so that only the passkey holder can decrypt it).

MasterKey2 supports PRF through three delivery modes, available on both <masterkey2-authenticate> and <masterkey2-register>:

Modeprf valuePRF output deliveryUse case
Event"event"Included in the success event as detail.prfOutput (base64url)Client-side encryption — the consuming app derives keys in the browser
Callback"callback"POSTed to the prf-callback URL as { challengeId, prfOutput, capabilities }Server-side storage — your backend receives and stores the PRF output
Both"both"Both event and callbackApps that need both client-side and server-side access

How it works

During authentication (<masterkey2-authenticate>):

  1. The component (inside its iframe) requests a deterministic PRF salt from MasterKey2 (SHA-256(tenant_id:rp_id))
  2. The salt is injected into the WebAuthn get() ceremony as a PRF extension
  3. The authenticator evaluates the PRF and returns a deterministic output
  4. The PRF output is delivered to the consuming app via the configured mode (event, callback, or both)
  5. The PRF output is also available server-side via POST /api/v1/verify-auth (returned as prfResult, consumed on first read)

During registration (<masterkey2-register>):

  1. The SDK sends prf: true in the register/start request
  2. MasterKey2 returns a PRF salt alongside the registration challenge
  3. The PRF extension is evaluated during navigator.credentials.create()
  4. A SHA-256 hash commitment (SHA-256(challengeId + ':' + prfOutput)) is sent to register/finish — the raw PRF output is never sent to MasterKey2
  5. The PRF output is delivered to the consuming app via the configured mode
  6. The PASSKEY_ADDED event includes prfEnabled: true/false indicating authenticator support

Example: Client-side encryption (event mode)

<masterkey2-authenticate
  api-base-url="https://auth.example.com"
  token="SESSION_TOKEN"
  prf="event">
</masterkey2-authenticate>

<script>
  const el = document.querySelector('masterkey2-authenticate');

  el.addEventListener('success', async (e) => {
    const { prfOutput, capabilities } = e.detail;

    if (!prfOutput) {
      console.warn('Authenticator does not support PRF');
      return;
    }

    // Derive an AES-256-GCM key from the PRF output via HKDF
    const prfBytes = base64urlDecode(prfOutput);
    const keyMaterial = await crypto.subtle.importKey('raw', prfBytes, 'HKDF', false, ['deriveKey']);
    const aesKey = await crypto.subtle.deriveKey(
      { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: new TextEncoder().encode('my-app-v1') },
      keyMaterial,
      { name: 'AES-GCM', length: 256 },
      false,
      ['encrypt', 'decrypt']
    );

    // Use aesKey to encrypt/decrypt user data in the browser
  });
</script>

Example: Server-side PRF via callback

<masterkey2-authenticate
  api-base-url="https://auth.example.com"
  token="SESSION_TOKEN"
  prf="callback"
  prf-callback="/api/store-prf">
</masterkey2-authenticate>

Your server endpoint receives:

{
  "challengeId": "abc123...",
  "prfOutput": "base64url-encoded-prf-output",
  "capabilities": { "prf": true }
}

The request includes cookies (credentials: 'include'), so you can validate the user’s session.

Example: PRF during registration

<masterkey2-register
  api-base-url="https://auth.example.com"
  assertion-url="/api/mk2-user-token"
  name="My Passkey"
  prf="event">
</masterkey2-register>

<script>
  document.querySelector('masterkey2-register')
    .addEventListener('success', (e) => {
      if (e.detail.capabilities?.prf) {
        console.log('Passkey supports PRF — encryption features available');
        // Optionally store initial encrypted data using the PRF output
      } else {
        console.log('Passkey registered but does not support PRF');
      }
    });
</script>

PRF via the JavaScript API

// Authentication with PRF
const result = await auth.passkey.authenticate({
  prfSalt: 'base64url-encoded-salt',  // Your combined salt
  onSuccess: (result) => {
    if (result.prfOutput) {
      // Derive encryption key from result.prfOutput
    }
    console.log('PRF supported:', result.capabilities?.prf);
  }
});

// Registration with PRF
await auth.passkey.register({
  name: 'My Passkey',
  prf: 'event',
  onSuccess: (result) => {
    console.log('PRF supported:', result.capabilities?.prf);
    if (result.prfOutput) {
      // Use initial PRF output for setup
    }
  }
});

Browser and authenticator support

PRF requires both browser and authenticator support:

  • Chrome 132+ with platform authenticators (Touch ID, Windows Hello) and security keys that support the hmac-secret extension
  • Safari 18+ / iOS 18+ with platform authenticators
  • Firefox does not yet support PRF (as of 2025)

When PRF is not supported, prfOutput will be undefined and capabilities.prf will be false. Your app should handle this gracefully — PRF is an enhancement, not a requirement for authentication.

Security note: The PRF output is deterministic for a given authenticator + salt combination. It never leaves the client unless you explicitly send it (via event handlers or the callback mode). MasterKey2 only receives a hash commitment during registration, never the raw PRF output. During authentication, the PRF result is available via verify_auth (consumed on first read) only when the consuming app’s backend calls it.


Browser Detection

The composite components (<masterkey2-authenticate>, <masterkey2-register>) automatically detect the best authentication mode. The detection runs asynchronously and fires a mode-detected event when complete.

Detection Algorithm

StepConditionResultmode-detected reason
0amode="passkey" + WebAuthn availablepasskeyoverride
0bmode="passkey" + WebAuthn unavailable or insecureunavailablepasskey_unsupported
0cmode="qr"qroverride
0dmode="qr-desktop" + desktopqrforced_qr_desktop / override_qr_desktop
0emode="qr-desktop" + mobileContinue to step 1 (auto-detect)
1Not a secure context + mobileunavailableinsecure_mobile
2Not a secure context + desktopqrinsecure_desktop
3WebAuthn API not availableqrwebauthn_unsupported
4aMobile + iOS < 18passkey (direct, no iframe)ios_direct
4bMobile devicepasskeymobile
5Desktop + platform authenticator availablepasskeyplatform_authenticator / desktop_platform
6Firefox/Waterfox/Gecko UAqrfirefox
7Everything else (Chromium without platform auth)passkeychromium_native_qr

Why This Matters

  • Chromium on desktop natively shows a cross-device QR code inside its WebAuthn dialog, so the SDK uses passkey mode and lets the browser handle it.
  • Firefox on desktop does not show a cross-device QR code in its dialog, so the SDK provides its own QR flow via bv-masterkey2’s cross-device endpoints.
  • Mobile devices use passkey mode because the biometric authenticator (Face ID, fingerprint) is on the device itself.
  • On insecure contexts (HTTP, non-localhost), WebAuthn is unavailable. Desktop can still use QR mode (the QR opens bv-masterkey2’s own HTTPS page on the mobile device). Mobile has no fallback.

authenticatorAttachment (register component only)

The register component sets authenticatorAttachment based on the detected context:

ContextValueReason
MobileundefinedAllow all authenticator types (on-device biometrics or external)
Desktop (platform auth available)'cross-platform'Force mobile registration (platform auth is likely a password manager, not biometrics)
Desktop (Chromium, no platform auth)'cross-platform'Prompt Chromium’s cross-device QR for phone registration

Utility Functions

These are exported from the SDK and used internally by the composite components:

import { isMobileDevice, isWebAuthnAvailable, isSecureContext } from './masterkey2.js';

isMobileDevice()       // Uses navigator.userAgentData.mobile (Chromium) with
                       // maxTouchPoints + UA regex fallback (Safari/Firefox)

isWebAuthnAvailable()  // Checks window.PublicKeyCredential existence

isSecureContext()      // Checks window.isSecureContext (HTTPS or localhost)

TypeScript Types

All types are exported from the SDK entry point:

import MasterKey2, {
  // Event system
  MK2Events,

  // Error handling
  MK2ErrorCode,
  MK2Error,

  // Configuration
  type MasterKey2Config,

  // Module options
  type PasskeyAuthenticateOptions,
  type PasskeyRegisterOptions,
  type QRAuthOptions,
  type QRRegisterOptions,
  type QRSession,
  type PasswordLoginOptions,

  // Response types
  type AuthResponse,
  type AuthSuccessResponse,
  type AuthErrorResponse,

  // Data types
  type PasskeyInfo,

  // Cross-device functions (standalone, no MasterKey2 instance needed)
  startCrossDeviceRegister,
  watchChallengeStatus,
  type CrossDeviceStartResult,
  type StatusCallbacks,

  // Web Components (classes, if you need to extend)
  MasterKey2Passkey,
  MasterKey2QR,
  MasterKey2Status,
  MasterKey2Authenticate,
  MasterKey2Register,
} from './masterkey2.js';

Response Types

// Discriminated union returned by login/register methods
type AuthResponse = AuthSuccessResponse | AuthErrorResponse;

interface AuthSuccessResponse {
  success: true;
  redirectUrl?: string;
  user?: {
    id: string;           // bv-masterkey2 internal UUIDv7
    externalId: string;  // Tenant-provided identifier (email, username, etc.)
    displayName?: string;
  };
}

interface AuthErrorResponse {
  success: false;
  error_code?: string;    // Machine-readable code (e.g. 'credential_not_found')
  error: string;          // Human-readable message
}

Examples

Same-Origin Admin Login

The bv-masterkey2 admin login page (GET /) uses <masterkey2-authenticate> for passkey-based admin authentication. On success, it calls a server endpoint to create an admin session:

<!-- Server generates a session token for the admin tenant and passes it to the template -->
<masterkey2-authenticate api-base-url="{{ origin }}" token="{{ auth_token }}" silent></masterkey2-authenticate>

<script type="module">
  document.querySelector('masterkey2-authenticate')
    .addEventListener('success', async (e) => {
      const res = await fetch('/admin/passkey-login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ externalId: e.detail.user?.externalId }),
      });
      const data = await res.json();
      if (data.redirect) window.location.href = data.redirect;
    });
</script>

This demonstrates same-origin integration where the server generates a session token for tenant identification, the SDK handles WebAuthn, and the host page performs additional server-side validation (checking the user is in the admin table).

Passkey Login (minimal)

<script type="module" src="https://auth.example.com/sdk/masterkey2.js"></script>

<!-- Token fetched server-side from POST /api/v1/session-token -->
<masterkey2-authenticate
  api-base-url="https://auth.example.com"
  token="<session-token>">
</masterkey2-authenticate>

<script>
  document.querySelector('masterkey2-authenticate')
    .addEventListener('success', (e) => {
      window.location.href = e.detail.redirectUrl || '/';
    });
</script>

Multi-Method Login (passkey + password)

<masterkey2-authenticate
  api-base-url="https://auth.example.com"
  token="<session-token>">
</masterkey2-authenticate>

<hr>

<form id="password-form">
  <input type="text" id="username" placeholder="Username" />
  <input type="password" id="password" placeholder="Password" />
  <button type="submit">Sign in</button>
</form>

<div id="error"></div>

<script type="module">
import MasterKey2, { MK2Events } from 'https://auth.example.com/sdk/masterkey2.js';

const auth = new MasterKey2({ apiBaseUrl: 'https://auth.example.com' });

// Passkey success
document.querySelector('masterkey2-authenticate')
  .addEventListener('success', (e) => {
    window.location.href = e.detail.redirectUrl || '/';
  });

// Password login
document.getElementById('password-form').onsubmit = async (e) => {
  e.preventDefault();
  await auth.password.login({
    username: document.getElementById('username').value,
    password: document.getElementById('password').value,
    onSuccess: () => { window.location.href = '/'; },
    onError: (err) => { document.getElementById('error').textContent = err.message; }
  });
};
</script>

Event-Driven Login

<button id="passkey-btn">Sign in with Passkey</button>
<button id="qr-btn">Sign in with QR Code</button>
<img id="qr-img" style="display:none" />
<div id="status"></div>

<script type="module">
import MasterKey2, { MK2Events } from './masterkey2.js';

const auth = new MasterKey2({
  apiBaseUrl: 'https://auth.example.com',
  debug: true
});

// Single handler for all auth success
auth.on(MK2Events.AUTH_SUCCESS, (e) => {
  window.location.href = e.detail.redirectUrl || '/';
});

auth.on(MK2Events.AUTH_ERROR, (e) => {
  if (e.detail.code === 'user_cancelled') return;
  document.getElementById('status').textContent = e.detail.error.message;
});

auth.on(MK2Events.QR_GENERATED, (e) => {
  document.getElementById('qr-img').src = e.detail.qrUrl;
  document.getElementById('qr-img').style.display = 'block';
});

auth.on(MK2Events.QR_STATUS_CHANGE, (e) => {
  document.getElementById('status').textContent = `Status: ${e.detail.status}`;
});

document.getElementById('passkey-btn').onclick = () => auth.passkey.authenticate();
document.getElementById('qr-btn').onclick = () => auth.qr.startAuth();
</script>

Server-side API endpoint (e.g. Astro):

// src/pages/api/mk2-user-token.ts — same-origin, validates session cookie
export const GET: APIRoute = async ({ cookies }) => {
  const session = validateSession(cookies);
  const res = await fetch(`${MASTERKEY2_URL}/api/v1/user-token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-API-KEY': MK2_API_KEY },
    body: JSON.stringify({ externalId: session.email, displayName: session.email, ttl: 30 }),
  });
  const data = await res.json();
  return Response.json({ userToken: data.userToken }, {
    headers: { 'Cache-Control': 'no-store' },
  });
};

Client-side:

<masterkey2-register
  api-base-url="https://auth.example.com"
  assertion-url="/api/mk2-user-token"
  name="My Passkey"
  qr-position="below">
</masterkey2-register>

<script>
  document.querySelector('masterkey2-register')
    .addEventListener('success', () => {
      location.reload();
    });
</script>

No token fetched at page load. The component fetches a fresh 30-second token on click, and the QR code renders inside a cross-origin iframe.

Cross-Origin Registration (Pre-fetched Token)

For simpler integrations where the page is short-lived:

Server-side (e.g. Astro frontmatter):

const tokenResponse = await fetch(`${MASTERKEY2_URL}/api/v1/user-token`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-API-KEY': MK2_API_KEY,
  },
  body: JSON.stringify({ externalId: user.email, displayName: user.name }),
});
const { userToken } = await tokenResponse.json();

Client-side:

<masterkey2-register
  api-base-url="https://auth.example.com"
  token={userToken}
  name="My Passkey"
  qr-position="below">
</masterkey2-register>

<script>
  document.querySelector('masterkey2-register')
    .addEventListener('success', () => {
      location.reload();
    });
</script>

Troubleshooting

”Failed to fetch” / server_unreachable errors

  • Verify apiBaseUrl is correct and the bv-masterkey2 server is running
  • Check CORS: the bv-masterkey2 server must allow your origin. API routes allow Authorization and Content-Type headers.
  • If using cross-origin registration, ensure the token attribute is set
  • “Request timed out” means the server was reachable but did not respond within the timeout window (default 30s). Check server health or increase the timeout.

WebAuthn not working

  • WebAuthn requires HTTPS (except localhost for development)
  • Check window.isSecureContext in the browser console
  • Verify the user has registered a passkey (check with auth.management.list())
  • On Firefox, the SDK falls back to QR mode — this is expected

QR code issues

  • If the QR code doesn’t appear, check the browser console for errors (enable debug attribute)
  • QR codes auto-refresh 1 minute before expiration (default: 4 min mark of a 5 min window)
  • If a QR is scanned but nothing happens, check that the mobile device can reach the bv-masterkey2 server over HTTPS
  • cancelled status means the mobile user dismissed the WebAuthn prompt — the SDK automatically regenerates a fresh QR
  • Transient network errors during QR polling are retried up to 3 times per polling session. If a session exhausts its retries, polling restarts on the same challenge after a 3-second delay (up to 3 restarts). After all restarts are exhausted, the flow terminates with server_unreachable or rate_limited. Enable debug to see retry and restart logs.

Cross-device QR fails on different domains

If the QR code is scanned and the mobile WebAuthn prompt fails (or never appears), your app likely needs to serve /.well-known/webauthn. This is required when bv-masterkey2 runs on a different domain from your app. See Cross-Domain Passkeys.

configuration_error / “Authentication Unavailable”

The composite components show an amber error panel with “Authentication Unavailable” when:

  1. Missing required attribute — Both components require api-base-url. <masterkey2-authenticate> additionally requires token (session token). <masterkey2-register> requires either token (user token) or assertion-url. If any required attribute is missing, the component renders the error immediately on mount.

  2. Tenant rp_id mismatch — The tenant’s rp_id doesn’t match the page origin. This happens when the API key belongs to a tenant whose rp_id was set to a different domain than the website using the component.

Fix for missing token: Fetch the appropriate token server-side and pass it as an HTML attribute. For login, use POST /api/v1/session-token. For registration, use POST /api/v1/user-token (or set assertion-url for just-in-time fetching). See Cross-Origin Registration.

Fix for rp_id mismatch: Use the API key for the tenant whose rp_id matches your website’s domain. You can check tenant details in the admin dashboard or by running bv-masterkey2-cli tenant add with the correct domain — it will print the existing tenant info if one already exists for that rp_id.

user_disabled error on login or registration

The server returns error_code: "user_disabled" when a user account has been soft-disabled by the tenant (via POST /api/v1/users/{external_id}/disable or the admin UI). The user’s passkeys are preserved but authentication is blocked. The tenant can re-enable the user to restore access.

user_cancelled errors firing unexpectedly

This is normal. NotAllowedError from the browser (user dismissed the WebAuthn dialog) is classified as user_cancelled. Handle it silently:

onError: (error) => {
  if (error.code === 'user_cancelled') return; // Expected, ignore
  showError(error.message);
}

Component shows “Detecting browser capabilities…”

The composite components run an async detection step on mount. If this message persists, it likely means isUserVerifyingPlatformAuthenticatorAvailable() is hanging. This can happen in certain browser/OS combinations. Set mode="passkey" or mode="qr" to bypass detection.