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
- Quick Start
- Configuration
- JavaScript API
- Event System
- Error Handling
- Web Components
- Composite Components (recommended)
- Primitive Components
- Cross-Origin Registration
- PRF Extension
- Browser Detection
- TypeScript Types
- Examples
- Troubleshooting
Installation
ESM Import (recommended)
<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.
}
| Option | Default | Description |
|---|---|---|
apiBaseUrl | — | Required. bv-masterkey2 server URL (e.g. https://auth.example.com). Trailing slashes are stripped. |
debug | false | Logs SDK activity to console as [MasterKey2 SDK] ... |
timeout | 30000 | Request timeout in milliseconds. Enforced via AbortController — requests that take longer are aborted and surface as server_unreachable. |
qrPollInterval | 2000 | How often to poll for QR status when WebSocket is unavailable (ms) |
qrExpirationTime | 300000 | Client-side QR code expiration. QR auto-refreshes 1 minute before this. |
token | — | Registration 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/start → navigator.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 (orundefinedif the authenticator doesn’t support PRF)capabilities.prf—trueif the authenticator produced a PRF output,falseotherwise
Returns: Promise<AuthResponse>
Events emitted: PASSKEY_START → PASSKEY_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/start → navigator.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: truein the request body) - The PRF extension is evaluated during
navigator.credentials.create() - A SHA-256 hash commitment (
SHA-256(challengeId + ':' + prfOutput)) is sent toregister/finishasprfHash— the raw PRF output is never sent to the server - The success result includes
capabilities.prf(trueif 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 toprfCallback
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
authenticatorAttachmentis 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: pending → scanned → completed | 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
| Event | Detail |
|---|---|
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:
prfOutputandcapabilitiesare only present in success events when PRF was requested (viaprfSalton authenticate, orprfon register).prfOutputis base64url-encoded.PASSKEY_ADDED.prfEnabledindicates 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:
| Attribute | Default | Description |
|---|---|---|
api-base-url | — | Required. bv-masterkey2 server URL. The component shows a configuration error if missing. |
debug | — | Enable console logging (boolean attribute) |
silent | — | Suppress internal status messages (boolean attribute) |
mode | — | Override 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 |
token | — | Required. Session token identifying the tenant (obtained server-side from POST /api/v1/session-token). The component shows a configuration error if missing. |
prf | — | Enable 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-callback | — | URL 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:
| Event | Detail | Description |
|---|---|---|
ready | — | Iframe 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:
| Attribute | Default | Description |
|---|---|---|
api-base-url | — | Required. bv-masterkey2 server URL. The component shows a configuration error if missing. |
debug | — | Enable console logging (boolean attribute) |
silent | — | Suppress 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 |
token | — | User 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-url | — | URL 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-inline | — | Render QR immediately without popover (boolean attribute) |
prf | — | Enable 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-callback | — | URL 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,authenticatorAttachmentisundefined(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:
| Attribute | Default | Description |
|---|---|---|
api-base-url | — | Required. bv-masterkey2 server URL |
debug | — | Enable console logging |
silent | — | Suppress internal status messages |
label | '🔐 Sign in with Passkey' | Button text |
button-class | 'primary-button' | CSS class for the button |
Events:
| Event | Detail |
|---|---|
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:
| Attribute | Default | Description |
|---|---|---|
api-base-url | — | Required. bv-masterkey2 server URL |
debug | — | Enable console logging |
silent | — | Suppress internal status messages |
auto-start | — | Auto-start QR flow on mount (boolean attribute) |
show-controls | true | Show Refresh/Cancel buttons |
Events:
| Event | Detail |
|---|---|
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:
| Attribute | Default | Description |
|---|---|---|
status | 'idle' | Current state: 'idle' | 'loading' | 'success' | 'error' |
message | '' | Status text to display |
Events:
| Event | Detail |
|---|---|
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 atGET /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)
-
Your server calls
POST /api/v1/user-tokenwith 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
ttlfield is optional (seconds, clamped to 5..=600, default 600). -
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' }); -
The SDK sends
Authorization: Bearer <token>on registration API calls instead of relying on cookies.
Approach B: Just-in-Time Token (recommended for dashboards)
For long-lived pages where a pre-fetched token might expire before the user clicks “Register”, use the assertion-url attribute instead of token:
-
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 }); } -
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> -
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:
fetch(assertionUrl, { credentials: 'include' })— sends the user’s session cookie to your endpoint- Your endpoint returns
{ userToken: "..." }
In QR mode (desktop):
- The component creates an iframe pointing to
${apiBaseUrl}/register-embed - On iframe load, sends
postMessage({ type: 'init', token, name })to the iframe - The iframe (running on bv-masterkey2’s origin) generates the QR code and watches for status
- Status events (
scanned,success,error,expired,cancelled) are relayed back to the parent viapostMessage
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):
- The token is sent as
Authorization: Beareron the registration API calls (begin_registration,finish_registration) - No iframe is used — the browser’s native WebAuthn dialog handles the flow directly
Cross-Domain Passkeys (Related Origin Requests)
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
- Desktop Firefox on
myapp.comstarts a cross-device QR flow - Mobile Safari/Chrome scans the QR code, opens
auth.bankvault.com - bv-masterkey2 returns
rpId: 'myapp.com'(from the tenant’srp_idsetting) - The mobile browser calls
navigator.credentials.get({ rpId: 'myapp.com' }) - Because
myapp.com≠auth.bankvault.com, the browser fetcheshttps://myapp.com/.well-known/webauthn - It finds
auth.bankvault.comin theoriginsarray — WebAuthn ceremony proceeds
When is this needed?
| Scenario | ROR needed? |
|---|---|
Direct passkey login on myapp.com | No — RP ID matches the page domain |
| Cross-device QR → mobile opens bv-masterkey2 on a different domain | Yes |
| Same domain (e.g., same host, different ports in dev) | No |
Browser support
| Browser | ROR Support |
|---|---|
| Chrome 128+ (July 2024) | Yes |
| Safari 18+ / iOS 18+ (Sep 2024) | Yes |
| Firefox | No — 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>:
| Mode | prf value | PRF output delivery | Use 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 callback | Apps that need both client-side and server-side access |
How it works
During authentication (<masterkey2-authenticate>):
- The component (inside its iframe) requests a deterministic PRF salt from MasterKey2 (
SHA-256(tenant_id:rp_id)) - The salt is injected into the WebAuthn
get()ceremony as a PRF extension - The authenticator evaluates the PRF and returns a deterministic output
- The PRF output is delivered to the consuming app via the configured mode (event, callback, or both)
- The PRF output is also available server-side via
POST /api/v1/verify-auth(returned asprfResult, consumed on first read)
During registration (<masterkey2-register>):
- The SDK sends
prf: truein theregister/startrequest - MasterKey2 returns a PRF salt alongside the registration challenge
- The PRF extension is evaluated during
navigator.credentials.create() - A SHA-256 hash commitment (
SHA-256(challengeId + ':' + prfOutput)) is sent toregister/finish— the raw PRF output is never sent to MasterKey2 - The PRF output is delivered to the consuming app via the configured mode
- The
PASSKEY_ADDEDevent includesprfEnabled: true/falseindicating 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-secretextension - 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
| Step | Condition | Result | mode-detected reason |
|---|---|---|---|
| 0a | mode="passkey" + WebAuthn available | passkey | override |
| 0b | mode="passkey" + WebAuthn unavailable or insecure | unavailable | passkey_unsupported |
| 0c | mode="qr" | qr | override |
| 0d | mode="qr-desktop" + desktop | qr | forced_qr_desktop / override_qr_desktop |
| 0e | mode="qr-desktop" + mobile | Continue to step 1 (auto-detect) | — |
| 1 | Not a secure context + mobile | unavailable | insecure_mobile |
| 2 | Not a secure context + desktop | qr | insecure_desktop |
| 3 | WebAuthn API not available | qr | webauthn_unsupported |
| 4a | Mobile + iOS < 18 | passkey (direct, no iframe) | ios_direct |
| 4b | Mobile device | passkey | mobile |
| 5 | Desktop + platform authenticator available | passkey | platform_authenticator / desktop_platform |
| 6 | Firefox/Waterfox/Gecko UA | qr | firefox |
| 7 | Everything else (Chromium without platform auth) | passkey | chromium_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:
| Context | Value | Reason |
|---|---|---|
| Mobile | undefined | Allow 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>
Cross-Origin Registration (Just-in-Time Token, recommended)
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
apiBaseUrlis correct and the bv-masterkey2 server is running - Check CORS: the bv-masterkey2 server must allow your origin. API routes allow
AuthorizationandContent-Typeheaders. - If using cross-origin registration, ensure the
tokenattribute is set - “Request timed out” means the server was reachable but did not respond within the
timeoutwindow (default 30s). Check server health or increase the timeout.
WebAuthn not working
- WebAuthn requires HTTPS (except
localhostfor development) - Check
window.isSecureContextin 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
debugattribute) - 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
cancelledstatus 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_unreachableorrate_limited. Enabledebugto 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:
-
Missing required attribute — Both components require
api-base-url.<masterkey2-authenticate>additionally requirestoken(session token).<masterkey2-register>requires eithertoken(user token) orassertion-url. If any required attribute is missing, the component renders the error immediately on mount. -
Tenant
rp_idmismatch — The tenant’srp_iddoesn’t match the page origin. This happens when the API key belongs to a tenant whoserp_idwas 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.