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
- 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).
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 |
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
* and the PRF output is included in success events. */
prfSalt?: string;
onSuccess?: (result: AuthResponse & { prfOutput?: string }) => void;
onError?: (error: MK2Error) => void;
}
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.external_id);
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)
onSuccess?: (result: AuthResponse) => void;
onError?: (error: MK2Error) => void;
}
Returns: Promise<AuthResponse>
Events emitted: PASSKEY_ADDED (on success) 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.
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; external_id?: string; display_name?: 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 } |
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 } |
PASSKEY_DELETED | { passkeyId: string } |
PASSKEYS_LOADED | { count: number } |
AUTH_SUCCESS | { redirectUrl?: string, user?: UserInfo } |
AUTH_ERROR | { error: Error | string, code?: string } |
Where UserInfo is { id: string, external_id: string, display_name?: string }.
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.SERVER_ERROR // 'server_error' — server returned an error
MK2ErrorCode.UNKNOWN // 'unknown' — unclassified error
MK2Error Class
class MK2Error extends Error {
readonly code: MK2ErrorCodeType;
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 | window.location.origin | bv-masterkey2 server URL |
debug | — | Enable console logging (boolean attribute) |
silent | — | Suppress internal status messages (boolean attribute) |
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 this attribute is missing. |
prf | — | Enable PRF extension (boolean attribute). When set, the iframe requests a deterministic PRF salt from the server and includes the PRF result in finish_authentication. The consuming app retrieves the PRF result via verify_auth. |
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? } | Authentication completed. Use challengeId with verify_auth to get user info and optional prfResult. |
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.”
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 | window.location.origin | bv-masterkey2 server URL |
debug | — | Enable console logging (boolean attribute) |
silent | — | Suppress internal status messages (boolean attribute) |
mode | 'auto' | Override detection: 'auto' | 'passkey' | 'qr' | 'qr-desktop' |
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) |
Events: Same as <masterkey2-authenticate> (mode-detected, success, error, expired).
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.”
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 | window.location.origin | 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 | window.location.origin | 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"] }
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 |
|---|---|---|---|
| 0 | mode attribute set to 'passkey' or 'qr' | Use that mode | override |
| 0b | mode attribute set to 'qr-desktop' + desktop | qr | override_qr_desktop |
| 0c | mode attribute set to '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 |
| 4 | 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
external_id: string; // Tenant-provided identifier (email, username, etc.)
display_name?: 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({ external_id: e.detail.user?.external_id }),
});
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
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
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
tokenattribute —<masterkey2-authenticate>requires atoken(session token).<masterkey2-register>requires either atoken(user token) or anassertion-url. If neither is provided, 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.