Security Architecture

masterkey2 is a multi-tenant WebAuthn-as-a-service platform. Consuming applications integrate via a server-side API (tenant API keys) and a browser-side JavaScript SDK (web components). This document explains the security properties of each authentication flow and the design decisions behind them.

Audience: Technical developers evaluating masterkey2 for integration into their web applications.


Table of Contents


Threat Model

What masterkey2 protects against

ThreatMitigation
PhishingWebAuthn origin binding — passkeys are cryptographically scoped to the RP ID. A phishing site on a different domain cannot use a victim’s passkey.
Credential replayEach authentication challenge is a one-time 32-byte random value with a 5-minute expiry. Atomic SQL claims (SET used=1 WHERE used=0) prevent double-use even in multi-instance deployments.
Credential theft at restPublic keys only — masterkey2 stores COSE public keys (P-256), not secrets. A database breach does not yield reusable credentials.
Cross-tenant passkey leakagePer-tenant RP IDs. Passkeys are cryptographically bound to the RP ID. Subdomain matching is opt-in and defaults to exact match.
API key compromiseKeys stored as SHA-256 hashes. Immediate rotation endpoint. Server-to-server only — API keys never reach the browser.
Brute force (admin)Strict rate limiting (5 requests per 12 seconds per IP) on admin authentication. Email OTP with 5-minute single-use nonce.
Network tampering (MITM)Per-tenant ES256 signed assertions (JWT) on verify-auth responses. The consuming app verifies the signature with a pre-shared public key.
Compromised masterkey2 serverOpt-in proof forwarding: raw WebAuthn authentication proofs (authenticator data, signature, credential ID) forwarded to the consuming app, which independently verifies the ECDSA P-256 signature against stored credential public keys. The authenticator’s private key never leaves the device.
Authenticator cloningCounter validation on every authentication. If the counter does not strictly increase, the signature is rejected.
Session fixationSessions are created fresh on each successful authentication. UUIDv7 session IDs (122 bits of entropy).
Registration replayUser tokens are consumed (deleted) on successful registration. A captured token cannot register a second passkey.
XSS token extractionCross-device registration uses an iframe on the masterkey2 origin. The QR URL (which embeds the challenge) never enters the consuming app’s JavaScript context.
CSRFSameSite=Lax cookies for same-origin flows. Cross-origin flows use explicit Authorization: Bearer headers (not automatically attached by the browser).

What is out of scope

  • Compromised authenticator hardware — if a user’s security key or device is physically compromised, the attacker has the private key. masterkey2 can detect cloned authenticators via counters but cannot prevent use of the original.
  • Browser zero-days — a compromised browser can intercept any WebAuthn ceremony. This is a platform-level threat.
  • Denial of service beyond rate limiting — masterkey2 has per-IP rate limiting but does not implement CAPTCHA or proof-of-work challenges.
  • Compromised masterkey2 at registration time — proof forwarding trusts masterkey2 to provide the correct credential public key during registration. An attacker who controls the server during registration can substitute their own key. After registration, proof forwarding protects against server compromise.

HTTPS requirement

WebAuthn requires a secure context. All passkey operations require HTTPS (or localhost for development). The SDK detects insecure contexts and falls back to QR mode on desktop (redirecting the mobile device to the masterkey2 HTTPS origin) or shows an “unavailable” warning on mobile.


System Architecture

graph TB
    subgraph consumer["YOUR APPLICATION"]
        direction TB
        subgraph frontend["Frontend (Browser)"]
            login["masterkey2-authenticate<br/>token, prf, prf-callback"]
            register["masterkey2-register<br/>token | assertion-url, prf, prf-callback"]
        end
        subgraph backend["Backend Server"]
            apikey["Tenant API Key<br/>(server-side only, never in browser)"]
            tokenProv["Token Provisioning<br/>session tokens, user tokens"]
        end
    end

    subgraph mk2["MASTERKEY2 SERVICE"]
        direction TB
        sdk["JavaScript SDK<br/>(ESM, served from masterkey2 origin)"]
        webauthn["WebAuthn API Endpoints<br/>/auth/v1/*, /auth/cross-device/*"]
        api["Tenant API<br/>/api/v1/*"]
        admin["Admin Dashboard<br/>Passkey + email OTP login"]
        ws["WebSocket Push<br/>/auth/cross-device/ws"]
        subgraph db["Database"]
            sqlite["SQLite (standalone)<br/>+ in-memory state"]
            postgres["PostgreSQL (HA)<br/>all state persisted"]
        end
    end

    backend -- "X-API-KEY header<br/>POST /api/v1/user-token<br/>POST /api/v1/session-token<br/>POST /api/v1/verify-auth" --> api
    backend -- "HTML attribute<br/>token=..." --> frontend
    frontend -- "Authorization: Bearer<br/>(no cookies cross-origin)" --> webauthn
    frontend -- "WebSocket<br/>real-time status" --> ws
    frontend -. "Loads SDK script<br/>from masterkey2 origin" .-> sdk

    style consumer fill:#e8f0fe,stroke:#4285f4,stroke-width:2px
    style mk2 fill:#e6f4ea,stroke:#34a853,stroke-width:2px
    style db fill:#f3e8fd,stroke:#9334e6,stroke-width:1px
    style frontend fill:#fff,stroke:#4285f4
    style backend fill:#fff,stroke:#4285f4

Security properties: API key never reaches the browser. SDK served from masterkey2 origin. HTTPS required for WebAuthn. Per-tenant RP ID isolation. Bearer tokens (not cookies) for cross-origin flows.

The architecture enforces a strict separation between server-side secrets and browser-side operations:

  1. API keys are server-side only. Your backend calls masterkey2’s /api/v1/* endpoints with the X-API-KEY header. This key is never sent to or accessible from the browser.

  2. Tokens bridge the gap. Your backend requests short-lived tokens (session tokens for login, user tokens for registration) and passes them to the browser as HTML attributes on the SDK’s web components.

  3. The SDK handles the ceremony. The browser-side SDK communicates directly with masterkey2’s WebAuthn endpoints using Authorization: Bearer headers. No cookies cross origins.

  4. Real-time status via WebSocket. For cross-device flows, the SDK opens a WebSocket to masterkey2 to receive instant status updates (scanned, completed, cancelled) without polling.


Authentication Flows

Standard Passkey Login

sequenceDiagram
    participant B as Browser / SDK
    participant M as masterkey2 Server
    participant DB as Database

    B->>M: POST /auth/v1/authenticate/start<br/>{prf: true}
    Note right of M: Rate limit check<br/>Origin whitelist check<br/>RP ID resolution (Bearer → Origin → global)<br/>Tenant disabled check<br/>Compute prfSalt = SHA-256(tenant_id:rp_id)
    M->>DB: INSERT webauthn_challenge<br/>(32 random bytes, 5-min expiry,<br/>type: webauthn_login, tenant_id)
    M-->>B: {challengeId, options, prfSalt?}

    B->>B: navigator.credentials.get()<br/>+ PRF extension {eval: {first: salt}}<br/>User biometric / security key ceremony

    B->>M: POST /auth/v1/authenticate/finish<br/>{challengeId, credential, prfHash?}
    Note right of M: WebAuthn verification:<br/>• clientDataJSON.type = "webauthn.get"<br/>• Challenge match<br/>• SHA-256(rp_id) = rpIdHash<br/>• UP flag set<br/>• ES256 signature verify<br/>• Counter check (strictly increasing)<br/>• User disabled check<br/>• Atomic claim: SET used=1 WHERE used=0
    M->>DB: UPDATE challenge (used, status)<br/>Store prf_hash if provided
    M-->>B: {success, challengeId, user}

    opt PRF verification (server-to-server)
        Note over B,M: Consuming app calls verify_auth<br/>with same prfHash for comparison
    end

The standard login flow uses usernameless WebAuthn authentication. The user is not asked to identify themselves — the authenticator presents all available passkeys for the RP ID, and the user selects one via biometric or PIN.

Endpoints

StepEndpointMethod
Start/auth/v1/authenticate/startPOST
Finish/auth/v1/authenticate/finishPOST

Security checkpoints

On start:

  1. Rate limiting — 30 requests per 2 seconds per IP (standard tier). Prevents challenge enumeration.
  2. Origin whitelist — Referer/Origin header checked against the configured whitelist. Blocks requests from unauthorized domains.
  3. RP ID resolution — The tenant’s RP ID is resolved via a priority chain: Bearer token > Origin header > global config. This determines which passkeys the authenticator will offer.
  4. Tenant disabled check — If the tenant has been disabled by an admin, the request is rejected with 403.

On finish:

  1. Credential lookup — The credential ID from the WebAuthn response is looked up in the database. Unknown credentials are rejected.
  2. User disabled check — If the user has been disabled, the response includes error_code: "user_disabled" rather than a session.
  3. Challenge validity — The challenge must exist, not be expired (5 minutes), and be of type webauthn_login.
  4. WebAuthn signature verification — The complete W3C verification procedure:
    • clientDataJSON.type must be "webauthn.get" (prevents cross-ceremony confusion)
    • clientDataJSON.challenge must match the stored challenge (prevents replay)
    • clientDataJSON.origin hostname checked advisory-only (warn log) — supports cross-device flows and Related Origin Requests
    • SHA-256(rp_id) must equal authenticatorData.rpIdHash (cryptographic RP binding — authoritative RP ID validation)
    • User Present (UP) flag must be set in authenticator data (proof of human interaction)
    • ES256 (ECDSA P-256) signature over authenticatorData || SHA-256(clientDataJSON) must verify against the stored COSE public key
  5. Counter check — If either the stored or received counter is non-zero, the received counter must be strictly greater than the stored counter. Detects cloned authenticators.
  6. Atomic claimUPDATE SET used=1 WHERE used=0 prevents a race condition where two concurrent requests could both succeed with the same challenge.

What this mitigates

  • Phishing: The browser enforces that the Origin matches the RP ID. A phishing site cannot trigger a ceremony for your domain.
  • Replay: Each challenge is single-use (32 bytes of randomness, 5-minute TTL, atomic claim).
  • Credential stuffing: Not applicable — there are no passwords. The authenticator holds the private key.
  • Session hijacking: Sessions are created server-side with UUIDv7 IDs. They are not derived from any user-supplied input.

Cross-Device Authentication (QR)

sequenceDiagram
    participant D as Desktop Browser
    participant M as masterkey2 Server
    participant Mo as Mobile Device

    D->>M: POST /auth/cross-device/start<br/>{prf: true}
    Note right of M: RP ID resolution + tenant check<br/>Compute prfSalt = SHA-256(tenant_id:rp_id)
    M-->>D: {challengeId, qrCodeUrl, prfSalt?, timeoutSeconds: 600}
    Note left of D: Challenge: 32 bytes, 10-min expiry<br/>type: cross_device_auth, tenant_id stored

    D->>M: WS /auth/cross-device/ws<br/>(Origin whitelist check)
    Note left of D: QR rendered pointing to<br/>masterkey2 HTTPS URL<br/>+ challengeId + prfSalt

    Mo->>M: POST /auth/cross-device/options<br/>{challengeId}
    Note right of M: Challenge valid + type check<br/>RP ID from challenge.tenant_id<br/>status → in_progress
    M-->>D: WebSocket: "scanned"
    M-->>Mo: {challenge, rpId, allowCredentials}

    Mo->>Mo: navigator.credentials.get()<br/>+ PRF extension if prfSalt in URL<br/>User biometric ceremony

    Mo->>M: POST /auth/cross-device/finish<br/>{challengeId, credential, prfHash?}
    Note right of M: Full WebAuthn verification<br/>User disabled check<br/>Counter check<br/>Atomic claim: WHERE used=0<br/>AND status IN (pending, in_progress)<br/>Store prf_hash if provided
    M-->>D: WebSocket: "completed"

    D->>M: POST /auth/cross-device/status<br/>{challengeId}
    Note right of M: User disabled re-check<br/>Session created for desktop
    M-->>D: {status: "completed", user}

    rect rgba(0,0,0,0.03)
        Note over Mo,M: ALT: Cancel flow
        Mo->>M: POST /auth/cross-device/cancel
        Note right of M: status → failed<br/>error_code: "user_cancelled"
        M-->>D: WebSocket: "cancelled"
        Note left of D: SDK auto-regenerates QR
    end

    opt PRF callback (cross-device)
        Note over Mo,D: Mobile POSTs raw PRF output<br/>to prfCallback URL (embedded in QR)<br/>directly to consuming app
    end

Cross-device authentication is used when the desktop browser cannot directly perform a WebAuthn ceremony — typically on Firefox (which lacks a native cross-device QR dialog), on desktops without a platform authenticator, or in HTTP contexts where WebAuthn is unavailable on the desktop but the mobile device can reach masterkey2 over HTTPS.

Endpoints

StepEndpointMethodCalled by
Start/auth/cross-device/startPOSTDesktop
WebSocket/auth/cross-device/wsGETDesktop
Options/auth/cross-device/optionsPOSTMobile
Finish/auth/cross-device/finishPOSTMobile
Status/auth/cross-device/statusPOSTDesktop
Cancel/auth/cross-device/cancelPOSTMobile

Flow

  1. Desktop starts — Requests a challenge (32 bytes, 10-minute expiry). The tenant_id is stored on the challenge row so the mobile device can resolve the correct RP ID.
  2. Desktop opens WebSocket — Receives real-time status updates. Origin whitelist is checked on connect.
  3. QR code rendered — Points to {masterkey2_origin}/auth/cross-device?challengeId={uuid}. The challenge ID is an unguessable UUIDv7.
  4. Mobile scans QR — Calls /auth/cross-device/options with the challenge ID. The challenge status moves to in_progress, and "scanned" is broadcast to the desktop via WebSocket.
  5. Mobile completes WebAuthn — Full signature verification (same as standard login). The challenge is atomically claimed: SET used=1, status='completed' WHERE used=0 AND status IN ('pending', 'in_progress').
  6. Desktop receives completion — Via WebSocket or HTTP polling fallback. The desktop then calls /auth/cross-device/status, which performs a second user disabled check and creates a session for the desktop browser.

Cancel and retry

If the user cancels on mobile, POST /auth/cross-device/cancel marks the challenge as failed with error_code: "user_cancelled" and broadcasts "cancelled" via WebSocket. The SDK automatically generates a new QR code — the user does not need to refresh the page.

What this mitigates

  • Challenge as capability token — The challenge ID (UUIDv7) is the sole credential for the mobile device. It is unguessable, time-limited, and single-use.
  • Atomic claim prevents race conditions — In HA deployments with multiple masterkey2 instances, the SQL WHERE used=0 guard ensures only one completion succeeds, even if two mobile devices scan the same QR.
  • Tenant RP ID on the challenge — The mobile device resolves the RP ID from the challenge’s tenant_id, not from its own Origin header (which would be the masterkey2 origin, not the tenant’s origin). This ensures the correct passkeys are offered.
  • Desktop session via separate check — The desktop does not trust the WebSocket message alone. It makes a separate HTTP call to /auth/cross-device/status, which re-validates the user’s disabled status and creates a fresh session.

Server-side verification (verify_auth)

For consuming apps that need server-to-server verification (rather than trusting the browser’s success callback), masterkey2 provides:

POST /api/v1/verify-auth
Headers: X-API-KEY: <tenant-api-key>
Body: { "challenge_id": "..." }

This endpoint:

  • Checks status='completed' AND used=true AND verified=false
  • Atomically sets verified=true (one-time use, prevents replay)
  • Validates the user is not disabled and belongs to the requesting tenant
  • Returns user identity on success
  • If the tenant has a signing key, includes an assertion field (ES256 JWT) for tamper-proof verification
  • If proof forwarding is enabled, includes a proof object with raw WebAuthn authentication data (see Proof Forwarding)

Passkey Registration

sequenceDiagram
    participant A as Your App Server
    participant B as Browser / SDK
    participant M as masterkey2 Server

    A->>M: POST /api/v1/user-token<br/>X-API-KEY header<br/>{external_id, display_name, ttl: 5-600}
    Note right of M: API key hash lookup<br/>Tenant disabled check<br/>User upserted (create if new)
    M-->>A: {user_token: "ut_...", user_id}

    A->>B: Token as HTML attribute<br/>masterkey2-register token=ut_...<br/>(API key stays server-side)

    B->>M: POST /auth/v1/register/start<br/>Authorization: Bearer ut_...<br/>{name, prf: true}
    Note right of M: Token validated (non-consuming)<br/>User disabled check<br/>Origin vs RP ID match (422 if wrong)<br/>Compute prfSalt = SHA-256(tenant_id:rp_id)
    M-->>B: {challengeId, options, prfSalt?}

    B->>B: navigator.credentials.create()<br/>+ PRF extension {eval: {first: salt}}<br/>User creates passkey

    B->>M: POST /auth/v1/register/finish<br/>{challengeId, credential, prfHash?}
    Note right of M: Token consumed (single-use)<br/>clientDataJSON.type = "webauthn.create"<br/>Challenge + RP ID hash match<br/>UP flag set<br/>COSE public key extracted (P-256)<br/>AAGUID deduplication<br/>Store prf_hash if provided
    M-->>B: {success, prfEnabled?}

    opt PRF modes
        Note over B,M: event: prfOutput in success event<br/>callback: POST to prf-callback URL<br/>both: event + callback
    end

Registration creates a new passkey for an existing, authenticated user. It requires prior authentication — either a same-origin session cookie or a cross-origin user token.

Endpoints

StepEndpointMethod
Token (cross-origin)/api/v1/user-tokenPOST
Start/auth/v1/register/startPOST
Finish/auth/v1/register/finishPOST

Token lifecycle (cross-origin registration)

  1. Your backend calls POST /api/v1/user-token with the X-API-KEY header, providing the user’s external_id, display_name, and a ttl (5-600 seconds, default 600).
  2. masterkey2 upserts the user (creates if new) and returns a short-lived token.
  3. Your backend passes the token to the browser as an HTML attribute: <masterkey2-register token="...">.
  4. The SDK sends Authorization: Bearer <token> on registration requests.
  5. On register/start, the token is validated but not consumed — allowing the ceremony to be retried if the user cancels.
  6. On register/finish, the token is consumed (deleted) — preventing a stolen token from registering additional passkeys.

Security checkpoints

On start:

  1. Token or session validation — The user must be authenticated.
  2. User disabled check — Disabled users cannot register new passkeys.
  3. Origin vs RP ID validation — The Origin header hostname must match the tenant’s rp_id (exact match, or subdomain if subdomain_match is enabled). A mismatch returns HTTP 422 with error_code: "rp_id_origin_mismatch". This prevents accidentally registering passkeys under the wrong RP ID, which would make them unusable.

On finish:

  1. Token consumed — Single-use. A captured token cannot register a second passkey.
  2. WebAuthn registration verificationclientDataJSON.type must be "webauthn.create", challenge must match, RP ID hash must match, User Present flag must be set. Origin vs RP ID is checked advisory-only (warn log) — the RP ID hash in authenticator data is the authoritative validation, which supports cross-device flows and Related Origin Requests where the page origin legitimately differs from the RP ID. The COSE public key (P-256) is extracted from the attestation object.
  3. AAGUID deduplication — If the authenticator provides an AAGUID (Authenticator Attestation GUID), masterkey2 deletes any existing passkeys from the same authenticator for this user. This prevents passkey accumulation when users re-register from the same device (e.g., after a browser data clear).

What this mitigates

  • Unauthorized registration — Only authenticated users (via session or token) can register passkeys. Tokens are time-limited and single-use.
  • RP ID misconfiguration — The origin validation check catches mismatches early (at register/start), before the user goes through the WebAuthn ceremony.
  • Token replay — Token consumption on register/finish prevents a stolen token from being reused.
  • Passkey clutter — AAGUID-based deduplication keeps one passkey per authenticator per user.

Cross-Device Registration (QR + iframe)

sequenceDiagram
    participant D as Desktop Browser
    participant A as Your App Server
    participant M as masterkey2 Server
    participant Mo as Mobile Device

    Note left of D: Page rendered with<br/>masterkey2-register<br/>assertion-url=/api/mk2-user-token

    D->>D: User clicks "Register"

    D->>A: GET /api/mk2-user-token<br/>(same-origin, session cookie)
    Note left of A: App validates user session
    A->>M: POST /api/v1/user-token<br/>X-API-KEY, {ttl: 30}
    M-->>A: {user_token: "ut_..."}
    A-->>D: {token: "ut_..."}

    D->>D: Create cross-origin iframe<br/>to masterkey2/register-embed?origin=...
    Note left of D: Token via postMessage<br/>(never in URL — XSS-safe)
    D->>M: postMessage({type: "init", token, name, prf})

    M->>M: POST /auth/register/cross-device/start<br/>{name, prf: true}
    Note right of M: Token validated (non-consuming)<br/>User disabled + origin vs RP ID check<br/>Challenge with user_id pre-set (10-min expiry)<br/>Compute prfSalt = SHA-256(tenant_id:rp_id)
    M-->>D: postMessage: "qr-ready"<br/>QR rendered inside iframe

    Mo->>M: Scan QR → POST .../options
    Note right of M: status → in_progress
    M-->>D: postMessage: "scanned"

    Mo->>Mo: navigator.credentials.create()<br/>+ PRF extension if prfSalt in URL<br/>User creates passkey

    Mo->>M: POST /api/registration/finish<br/>{challengeId, credential, prfHash?}
    Note right of M: Full verification<br/>Atomic claim: WHERE used=0<br/>AAGUID dedup<br/>Passkey stored<br/>Store prf_hash if provided
    M-->>D: postMessage: "success"

    D->>D: Component emits success event<br/>(with prfOutput if prf=event|both)

    rect rgba(0,0,0,0.03)
        Note over D,M: Security properties
        Note left of D: JIT 30s token (not pre-fetched)<br/>iframe isolates QR URL from parent JS<br/>CSP frame-ancestors protection<br/>postMessage relay only (no raw challenge)
    end

Cross-device registration solves two problems: (1) registering a passkey on a mobile device from a desktop browser, and (2) doing so safely on long-lived pages where pre-fetched tokens might expire.

The JIT token pattern

For long-lived pages (dashboards, settings), pre-fetching a user token at page render risks expiration before the user clicks “Register”. The assertion-url attribute solves this:

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

When the user clicks “Register”:

  1. The component fetches /api/mk2-user-token from your app (same-origin, session cookie validates the user).
  2. Your app’s endpoint calls POST /api/v1/user-token with a short TTL (e.g., ttl: 30).

In QR mode (desktop): the component creates a cross-origin iframe pointing to masterkey2’s /register-embed page. The token is sent to the iframe via postMessage — it never appears in a URL or in the parent page’s DOM.

In passkey mode (mobile): the token is sent as Authorization: Bearer on the registration API calls directly. No iframe is used — the browser’s native WebAuthn dialog handles the flow.

iframe isolation

The iframe renders the QR code inside the masterkey2 origin. This provides two security properties:

  1. QR URL containment — The QR code URL (which contains the challenge ID) exists only within the iframe’s JavaScript context. An XSS vulnerability in the consuming app cannot extract the QR URL because cross-origin iframe access is blocked by the browser’s same-origin policy.

  2. postMessage relay — The iframe communicates with the parent page exclusively via structured postMessage events (qr-ready, scanned, success, error, expired, cancelled). The parent never receives the raw challenge or QR URL.

The iframe’s CSP header uses frame-ancestors <origin> where the origin is provided by the SDK via a ?origin= query parameter on the iframe URL. The server validates the origin format (scheme + host + optional port only; rejects paths, semicolons, quotes, and newlines to prevent CSP header injection) and reconstructs a clean origin string. If the ?origin= parameter is missing or invalid, the server returns 400 Bad Request. This restricts iframe embedding to the specific origin that created it, preventing clickjacking from unrelated sites. The same CSP pattern is used by /register-embed and /auth-embed.

Token semantics in cross-device registration

  • Non-consuming on startstart_cross_device_register validates the token without consuming it. If the mobile user cancels, the SDK can regenerate a QR code and retry within the token’s TTL.
  • Atomic claim on finishfinish_cross_device_register uses SET used=1 WHERE used=0 to prevent race conditions.

Multi-Tenant Isolation

Tenant Model and API Keys

graph TB
    subgraph tier1["TIER 1: Server-to-Server (API Keys)"]
        direction LR
        app_backend["Your Backend"] -- "X-API-KEY: bvsk_...<br/>HTTPS only" --> mk2_api["masterkey2 API<br/>/api/v1/*"]
        mk2_api --> db_hash["Database<br/>SHA-256 hash only<br/>(irreversible)"]
    end

    subgraph tier2["TIER 2: Server-to-Browser (Short-Lived Tokens)"]
        direction TB
        subgraph st["Session Token (st_)"]
            st_scope["Tenant-only scope"]
            st_ttl["24h TTL, reusable"]
            st_use["Identifies tenant for login flows"]
            st_attr["masterkey2-authenticate token=..."]
        end
        subgraph ut["User Token (ut_)"]
            ut_scope["User + Tenant scope"]
            ut_ttl["5-600s TTL, consumed on finish"]
            ut_use["Authenticates user for registration"]
            ut_attr["masterkey2-register token=..."]
        end
    end

    subgraph tier3["TIER 3: RP ID Resolution Chain"]
        direction TB
        p1{"Bearer Token<br/>present?"}
        p1 -- "Yes" --> t1["Extract tenant_id<br/>→ tenant.rp_id<br/>(definitive)"]
        p1 -- "No" --> p2{"Origin header<br/>present?"}
        p2 -- "Yes" --> t2["Parse hostname<br/>→ match tenant rp_id<br/>(exact or subdomain)"]
        p2 -- "No" --> t3["Global config<br/>RP_ID env var<br/>(admin login only)"]
        t1 --> check["Tenant disabled<br/>check (403)"]
        t2 --> check
    end

    subgraph storage["Token Storage (feature-gated)"]
        direction LR
        mem["SQLite: in-memory<br/>HashMap (lost on restart)"]
        pgdb["PostgreSQL: mk2_session_token<br/>mk2_user_token tables<br/>(survives restarts, shared across instances)"]
    end

    tier1 --> tier2
    tier2 --> tier3

    style tier1 fill:#fef7e0,stroke:#f9ab00,stroke-width:2px
    style tier2 fill:#e8f0fe,stroke:#4285f4,stroke-width:2px
    style tier3 fill:#e6f4ea,stroke:#34a853,stroke-width:2px
    style storage fill:#f3e8fd,stroke:#9334e6,stroke-width:1px
    style st fill:#fff,stroke:#4285f4
    style ut fill:#fff,stroke:#4285f4

Key properties: Prefixed UUIDv7 strings (st_ / ut_) enable unambiguous dispatch. API keys use bvsk_ prefix with 192 bits of randomness. Session tokens cannot register passkeys (tenant-only scope). Token expiry validated on every use; hourly background reaper purges expired tokens.

Each consuming application is a tenant in masterkey2, identified by a unique API key. Tenants are isolated at multiple levels:

API key security

PropertyDetail
Formatbvsk_<32-char-base64url> (192 bits of randomness)
StorageSHA-256 hash only. The cleartext key is shown once at creation and never stored.
LookupHash the incoming key, query by hash. No timing side-channels from string comparison.
RotationPOST /api/v1/rotate-key with the current key. Generates a new key and immediately invalidates the old one. No grace period.
PrefixThe first 8 characters of the base64url portion are stored for identification in admin UI and logs.

Per-tenant RP ID

Each tenant has its own rp_id (Relying Party ID). This is the critical isolation boundary:

  • Passkeys are cryptographically bound to the RP ID. A passkey registered under app.example.com cannot be used to authenticate against other.example.com — the browser and authenticator will refuse.
  • Subdomain matching is opt-in. By default, the Origin hostname must exactly match the tenant’s rp_id. Setting subdomain_match=true allows subdomains (e.g., rp_id=example.com matches app.example.com). This is opt-in to prevent a consumer tenant’s passkeys from leaking to an admin tenant on a subdomain.

Tenant disable/enable

A disabled tenant (non-null disabled_at) is rejected at every authentication boundary:

  • extract_tenant_from_api_key() — blocks all /api/v1/* calls (403)
  • resolve_rp_id() / resolve_rp_id_and_tenant() — blocks all WebAuthn flows (403)

All user data is preserved. Re-enabling the tenant restores access immediately.


Token Types and Scopes

masterkey2 uses two token types to bridge the server-to-browser gap. Both are prefixed UUIDv7 strings (st_ for session tokens, ut_ for user tokens) stored in-memory (SQLite) or in the database (PostgreSQL HA).

PropertySession Token (st_)User Token (ut_)
ScopeTenant onlyUser + Tenant
TTL24 hours (fixed)5-600 seconds (configurable)
ReusableUntil revoked or expiredConsumed on registration finish
Use caseAll cross-origin flows — identifies tenantRegistration flows — authenticates user cross-origin
Carriestenant_iduser_id, tenant_id, external_id, display_name
SDK attribute<masterkey2-authenticate token="..."><masterkey2-register token="...">
Bearer dispatchPrefix st_ → SessionToken storePrefix ut_ → UserToken store

A session token cannot be used to register a passkey. It only carries a tenant_id and is used solely to resolve the tenant’s RP ID for cross-origin flows. This prevents a leaked session token from being escalated to passkey registration.

Session token revocation:

  • finish_authentication does not revoke session tokens — they are long-lived tenant identifiers, not single-use.
  • Explicit revocation via DELETE /api/v1/session-token (self-authenticated — the token to revoke is sent as Authorization: Bearer). Idempotent.
  • 24h TTL expiry and hourly background reaper provide automatic cleanup.

User token revocation:

  • Registration flows consume user tokens via get_user_context_or_consume_token.

Token expiry enforcement:

  • Validated on every use (checked against expires_at).
  • A background reaper task runs hourly to purge expired tokens and admin nonces from the in-memory store or database.

RP ID Resolution Chain

When a WebAuthn request arrives, masterkey2 must determine which RP ID to use. This is resolved via a three-step priority chain:

PrioritySourceMechanismWhen used
1Bearer tokenExtract tenant_id from SessionToken or UserToken, look up tenant.rp_idCross-origin flows (SDK sends Authorization: Bearer)
2Origin headerParse hostname, match against tenant rp_id valuesSame-origin flows (browser sends Origin header)
3Global configRP_ID environment variableAdmin login (same-origin, no tenant context)

Why the chain matters: In cross-origin deployments, the Origin header will be the consuming app’s origin (e.g., app.example.com), not masterkey2’s origin. The Bearer token provides a definitive tenant identity regardless of the Origin header, avoiding ambiguity when multiple tenants share similar domain structures.

Disabled tenant check is enforced at every level of the chain. If the resolved tenant is disabled, the request is rejected with 403.


Defense in Depth

Rate Limiting

Three tiers of per-IP rate limiting (via tower-governor):

TierRoutesDefault burstReplenish intervalRationale
StrictAdmin login (POST /, /verify, /admin/passkey-login)5 requestsEvery 12 secondsProtects admin OTP and passkey login from brute force
StandardWebAuthn API (/auth/*, /api/qr/*)30 requestsEvery 2 secondsAllows normal usage while preventing abuse
ServiceServer-to-server API (/api/v1/*)60 requestsEvery 1 secondHigher limit for automated backend calls

IP extraction order: X-Forwarded-For > X-Real-IP > Forwarded header > peer socket IP. All values are configurable via environment variables.

Rate limit exceeded returns HTTP 429 (Too Many Requests). The SDK handles this with MK2ErrorCode.RATE_LIMITED and reads the Retry-After header for backoff.

HA note: Rate limiting is per-instance — there is no cross-instance synchronization. In HA deployments, the effective burst is multiplied by the replica count. Add edge rate limiting at the reverse proxy or load balancer (e.g., nginx limit_req, Traefik middleware, cloud WAF) for production HA.


Origin and Whitelist Controls

Two layers of enforcement (defense in depth):

  1. CORS layer — Applied to API routes. Parses the Origin header, extracts the hostname, and checks against the whitelist. Only whitelisted origins receive CORS headers. Browsers will block cross-origin responses without valid CORS headers.

  2. Server-side middlewaremw_referer_origin_whitelist_check runs on API routes independently of CORS. Extracts the hostname from Referer (preferred) or Origin header, checks against the whitelist. Returns 403 if not matched. This catches cases where CORS alone is insufficient (e.g., non-browser clients).

Whitelist format (WHITELIST env var, comma-separated):

FormatExampleMatch behavior
IP address192.168.1.1Exact IP match
Regex/^10\.1\.1\..*/Pattern match against hostname
Domainexample.comExact hostname match

Default whitelist: localhost,127.0.0.1.


Session Security

PropertyImplementation
Cookie flagsSecure (if HTTPS), SameSite=Lax, HttpOnly (via tower-sessions)
Session IDUUIDv7 (122 bits of entropy)
Inactivity timeout60 minutes
StorageIn-memory (SQLite standalone) or database (PostgreSQL HA)
CSRF protectionSameSite=Lax prevents cross-site POST with cookies. Cross-origin flows use Authorization: Bearer headers (not automatically attached by the browser).

Trade-off: in-memory sessions (standalone mode). Sessions are lost on restart. This eliminates the session store as an attack surface (no session fixation via database manipulation) but means users must re-authenticate after a server restart. In HA mode, cookie sessions are persisted to the mk2_tower_session PostgreSQL table, surviving restarts and shared across instances.


Challenge Lifecycle

Every WebAuthn ceremony starts with a challenge — 32 random bytes stored in the webauthn_challenge table.

States

pending  ──>  in_progress  ──>  completed
   │              │
   │              │          ──>  expired (implicit, via expires_at)
   │              │
   └──────────────└──────────>  failed (cancelled, user disabled, error)

Protections

PropertyMechanism
Uniqueness32 bytes of cryptographic randomness (UUIDv7 for ID, separate random bytes for challenge)
Expiry5 minutes for standard flows, 10 minutes for cross-device
Single useAtomic SQL: UPDATE SET used=1 WHERE used=0 (checks rows_affected)
One-time verifyverified flag prevents verify_auth replay: UPDATE SET verified=1 WHERE verified=0
Type bindingChallenge type (webauthn_login, webauthn_register, cross_device_auth, cross_device_register) is checked on use. A login challenge cannot be used for registration.
Tenant bindingCross-device challenges store tenant_id for RP ID resolution on the mobile device
CleanupHourly purge of expired challenges

User and Tenant Lifecycle Controls

User disable/enable

Disabled users (non-null disabled_at) are blocked at every authentication boundary. All passkeys are preserved — re-enabling restores access.

Enforcement points (returns error_code: "user_disabled"):

  • finish_authentication — blocks standard login
  • check_cross_device_auth_status — blocks cross-device login
  • finish_cross_device_auth — blocks cross-device login
  • begin_registration — blocks new passkey registration
  • start_cross_device_register — blocks cross-device registration
  • verify_auth — blocks server-to-server verification
POST /api/v1/users/{external_id}/disable
POST /api/v1/users/{external_id}/enable
Headers: X-API-KEY: <tenant-api-key>

User deletion

Hard-deletes the user and all dependent records (passkeys, challenges). Irreversible.

DELETE /api/v1/users/{external_id}
Headers: X-API-KEY: <tenant-api-key>

Activity tracking

last_authenticated_at is updated on every successful authentication. Use this to detect stale accounts and prompt passkey re-registration.


Signed Assertions

Per-tenant ECDSA P-256 signing keys protect the verify-auth response against tampering in transit. The response includes an optional assertion field — a JWS Compact Serialization (ES256 JWT).

PropertyDetail
AlgorithmES256 (ECDSA P-256)
Header{"alg":"ES256","kid":"<tenant_id>"}
Claimssub (external_id), uid (user_id), tid (tenant_id), cid (challenge_id), iat, exp (+60s)
Key storagePKCS#8 DER private key in tenant table. Public key derivable as JWK.
Key rotationPOST /api/v1/rotate-signing-key or admin UI. New tenants auto-generate a signing key.

Threat model: Protects against network-level attacks (MITM, compromised proxy, DNS hijack) where an attacker can intercept/modify the HTTP response but does not have access to the masterkey2 database.


Proof Forwarding

Proof forwarding extends the security model beyond signed assertions. While signed assertions protect against network-level tampering, a compromised masterkey2 server has the signing keys in its database and could forge assertions. Proof forwarding enables the consuming app to independently verify that a real WebAuthn ceremony occurred.

How it works

During every passkey authentication, the authenticator’s private key (which never leaves the device) produces a unique ECDSA signature over the challenge, authenticator data, and origin. When proof forwarding is enabled, masterkey2 stores these raw proof bytes after verification and includes them in the verify-auth response.

Security properties

PropertyDetail
Opt-inPer-tenant proof_forwarding boolean, default false
Data forwardedcredentialId, authenticatorData, clientDataJSON, signature (all base64url-encoded)
UnforgeableThe authenticator’s ECDSA P-256 private key never leaves the hardware. A compromised server cannot produce a valid signature.
Trust boundaryTrust masterkey2 at registration time (for credential public key). After that, every authentication is independently verifiable.

Verification algorithm

The consuming app:

  1. Stores credential public keys (JWK) from GET /api/v1/users/{external_id}/credentials after registration
  2. On each verify-auth, receives the proof object
  3. Computes signed_data = authenticator_data || SHA-256(client_data_json) (base64url-decoded values)
  4. Verifies the ECDSA P-256 DER signature over signed_data using the stored credential JWK public key

Three-tier security model

TierProtects againstConsuming app does
No assertionNothingTrust JSON
Signed JWTNetwork tamperingVerify JWT with public key
+ Proof forwardingCompromised serverStore credential public keys, verify WebAuthn signature

Limitations

  • Registration-time trust: If the server is compromised during registration, it could provide a fake credential public key. Proof forwarding only protects against post-registration compromise.
  • Per-tenant opt-in: Must be explicitly enabled. Default is disabled for backward compatibility.

PRF Extension and Vault Security

The WebAuthn PRF (Pseudo-Random Function) extension enables client-side cryptographic key derivation from passkey authentication. The PRF vault isolates key material in a cross-origin iframe so it never enters the consuming app’s JS context.

Deterministic salt model

PRF output depends on a deterministic salt computed server-side:

salt = SHA-256(tenant_id + ":" + rp_id)

This salt is returned by begin_authentication and start_cross_device_auth when prf: true is requested. No per-user stored salts are needed — the authenticator’s secret key provides per-user uniqueness.

Auth iframe isolation

The <masterkey2-authenticate> component creates an iframe pointing to /auth-embed on bv-masterkey2. Security properties:

  1. Hash commitment — The iframe/mobile computes SHA-256(challengeId + ":" + prfOutput) and sends only the hash to masterkey2 with finish_authentication. The raw PRF output is never sent to masterkey2 — it goes directly to the consuming app (via event, callback URL, or both depending on PRF mode).
  2. XSS resistance — Even if the consuming app has an XSS vulnerability, the attacker cannot forge a valid PRF hash commitment without the raw PRF output (which requires the physical authenticator).
  3. CSP hardening — The iframe’s Content-Security-Policy header uses frame-ancestors <origin> (not *), restricting which sites can embed it.
  4. Ephemeral hash — The PRF hash commitment is stored ephemerally — in-memory (standalone) or in the webauthn_challenge.prf_hash column (HA) — and consumed on first verify_auth read.

Threat model

ThreatMitigation
XSS in consuming appHash commitment model — masterkey2 never sees raw PRF output. verify_auth confirms hash match without exposing the value.
Compromised masterkey2 serverAttacker has deterministic salt and hash commitment, but not the passkey’s PRF secret (hardware-bound) or the raw PRF output.
Clickjackingframe-ancestors <origin> CSP prevents embedding by unauthorized sites
PRF hash replayHash commitment consumed on first verify_auth read — second read returns None
Callback interceptionConsuming app should accept only one PRF callback per challengeId; cancel the session on duplicates

Browser Compatibility and Mode Selection

The SDK’s <masterkey2-authenticate> and <masterkey2-register> components auto-detect the browser environment and select the best authentication mode.

Detection logic

ScenarioModeReason
Mobile + HTTPSPasskeyOn-device biometric (Face ID, Touch ID, fingerprint)
Mobile + HTTPUnavailableWebAuthn requires a secure context
Desktop + Chromium + HTTPSPasskeyChromium’s native WebAuthn dialog includes a cross-device QR option
Desktop + Firefox + HTTPSQRFirefox lacks a native cross-device QR prompt
Desktop + HTTPQRWebAuthn unavailable on desktop, but mobile can open masterkey2’s HTTPS URL
Any + mode="qr" attributeQRManual override (always show QR)

Graceful degradation

The detection chain ensures the best possible UX for every browser/device combination:

  1. If WebAuthn is available and the device is mobile, use direct passkey authentication (fastest, most seamless).
  2. If WebAuthn is available on desktop with Chromium, use the native dialog (which includes its own cross-device QR as a built-in option).
  3. If WebAuthn is unavailable or the browser lacks cross-device support (Firefox), fall back to a masterkey2-rendered QR code.
  4. If no secure context exists on mobile, show an “HTTPS required” warning — there is no fallback.

Error classification

The SDK classifies errors into typed codes for consistent handling:

CodeMeaningSDK behavior
user_cancelledUser dismissed the WebAuthn dialogAuto-retry immediately (QR mode regenerates)
credential_not_foundNo matching passkey for this RP IDRetry after 3 seconds
challenge_expiredChallenge TTL exceededRegenerate challenge
rate_limitedHTTP 429 from serverBack off per Retry-After header
configuration_errorRP ID / Origin mismatch (422)Show amber config error panel
server_unreachableNetwork errorShow error message
server_error5xx responseRetry after 2 seconds
webauthn_not_supportedBrowser lacks WebAuthn APIShow unavailable message

Configuration errors (rp_id_origin_mismatch) are surfaced distinctly from runtime errors because they indicate a deployment misconfiguration that the site operator needs to fix, not a transient issue.