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
- System Architecture
- Authentication Flows
- Multi-Tenant Isolation
- Defense in Depth
- Browser Compatibility and Mode Selection
Threat Model
What masterkey2 protects against
| Threat | Mitigation |
|---|---|
| Phishing | WebAuthn origin binding — passkeys are cryptographically scoped to the RP ID. A phishing site on a different domain cannot use a victim’s passkey. |
| Credential replay | Each 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 rest | Public keys only — masterkey2 stores COSE public keys (P-256), not secrets. A database breach does not yield reusable credentials. |
| Cross-tenant passkey leakage | Per-tenant RP IDs. Passkeys are cryptographically bound to the RP ID. Subdomain matching is opt-in and defaults to exact match. |
| API key compromise | Keys 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 server | Opt-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 cloning | Counter validation on every authentication. If the counter does not strictly increase, the signature is rejected. |
| Session fixation | Sessions are created fresh on each successful authentication. UUIDv7 session IDs (122 bits of entropy). |
| Registration replay | User tokens are consumed (deleted) on successful registration. A captured token cannot register a second passkey. |
| XSS token extraction | Cross-device registration uses an iframe on the masterkey2 origin. The QR URL (which embeds the challenge) never enters the consuming app’s JavaScript context. |
| CSRF | SameSite=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:
-
API keys are server-side only. Your backend calls masterkey2’s
/api/v1/*endpoints with theX-API-KEYheader. This key is never sent to or accessible from the browser. -
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.
-
The SDK handles the ceremony. The browser-side SDK communicates directly with masterkey2’s WebAuthn endpoints using
Authorization: Bearerheaders. No cookies cross origins. -
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
| Step | Endpoint | Method |
|---|---|---|
| Start | /auth/v1/authenticate/start | POST |
| Finish | /auth/v1/authenticate/finish | POST |
Security checkpoints
On start:
- Rate limiting — 30 requests per 2 seconds per IP (standard tier). Prevents challenge enumeration.
- Origin whitelist — Referer/Origin header checked against the configured whitelist. Blocks requests from unauthorized domains.
- 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.
- Tenant disabled check — If the tenant has been disabled by an admin, the request is rejected with 403.
On finish:
- Credential lookup — The credential ID from the WebAuthn response is looked up in the database. Unknown credentials are rejected.
- User disabled check — If the user has been disabled, the response includes
error_code: "user_disabled"rather than a session. - Challenge validity — The challenge must exist, not be expired (5 minutes), and be of type
webauthn_login. - WebAuthn signature verification — The complete W3C verification procedure:
clientDataJSON.typemust be"webauthn.get"(prevents cross-ceremony confusion)clientDataJSON.challengemust match the stored challenge (prevents replay)clientDataJSON.originhostname checked advisory-only (warn log) — supports cross-device flows and Related Origin RequestsSHA-256(rp_id)must equalauthenticatorData.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
- 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.
- Atomic claim —
UPDATE SET used=1 WHERE used=0prevents 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
| Step | Endpoint | Method | Called by |
|---|---|---|---|
| Start | /auth/cross-device/start | POST | Desktop |
| WebSocket | /auth/cross-device/ws | GET | Desktop |
| Options | /auth/cross-device/options | POST | Mobile |
| Finish | /auth/cross-device/finish | POST | Mobile |
| Status | /auth/cross-device/status | POST | Desktop |
| Cancel | /auth/cross-device/cancel | POST | Mobile |
Flow
- Desktop starts — Requests a challenge (32 bytes, 10-minute expiry). The
tenant_idis stored on the challenge row so the mobile device can resolve the correct RP ID. - Desktop opens WebSocket — Receives real-time status updates. Origin whitelist is checked on connect.
- QR code rendered — Points to
{masterkey2_origin}/auth/cross-device?challengeId={uuid}. The challenge ID is an unguessable UUIDv7. - Mobile scans QR — Calls
/auth/cross-device/optionswith the challenge ID. The challenge status moves toin_progress, and"scanned"is broadcast to the desktop via WebSocket. - 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'). - 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=0guard 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
assertionfield (ES256 JWT) for tamper-proof verification - If proof forwarding is enabled, includes a
proofobject 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
| Step | Endpoint | Method |
|---|---|---|
| Token (cross-origin) | /api/v1/user-token | POST |
| Start | /auth/v1/register/start | POST |
| Finish | /auth/v1/register/finish | POST |
Token lifecycle (cross-origin registration)
- Your backend calls
POST /api/v1/user-tokenwith theX-API-KEYheader, providing the user’sexternal_id,display_name, and attl(5-600 seconds, default 600). - masterkey2 upserts the user (creates if new) and returns a short-lived token.
- Your backend passes the token to the browser as an HTML attribute:
<masterkey2-register token="...">. - The SDK sends
Authorization: Bearer <token>on registration requests. - On
register/start, the token is validated but not consumed — allowing the ceremony to be retried if the user cancels. - On
register/finish, the token is consumed (deleted) — preventing a stolen token from registering additional passkeys.
Security checkpoints
On start:
- Token or session validation — The user must be authenticated.
- User disabled check — Disabled users cannot register new passkeys.
- Origin vs RP ID validation — The
Originheader hostname must match the tenant’srp_id(exact match, or subdomain ifsubdomain_matchis enabled). A mismatch returns HTTP 422 witherror_code: "rp_id_origin_mismatch". This prevents accidentally registering passkeys under the wrong RP ID, which would make them unusable.
On finish:
- Token consumed — Single-use. A captured token cannot register a second passkey.
- WebAuthn registration verification —
clientDataJSON.typemust 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. - 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/finishprevents 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”:
- The component fetches
/api/mk2-user-tokenfrom your app (same-origin, session cookie validates the user). - Your app’s endpoint calls
POST /api/v1/user-tokenwith 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:
-
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.
-
postMessage relay — The iframe communicates with the parent page exclusively via structured
postMessageevents (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 start —
start_cross_device_registervalidates 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 finish —
finish_cross_device_registerusesSET used=1 WHERE used=0to 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
| Property | Detail |
|---|---|
| Format | bvsk_<32-char-base64url> (192 bits of randomness) |
| Storage | SHA-256 hash only. The cleartext key is shown once at creation and never stored. |
| Lookup | Hash the incoming key, query by hash. No timing side-channels from string comparison. |
| Rotation | POST /api/v1/rotate-key with the current key. Generates a new key and immediately invalidates the old one. No grace period. |
| Prefix | The 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.comcannot be used to authenticate againstother.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. Settingsubdomain_match=trueallows subdomains (e.g.,rp_id=example.commatchesapp.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).
| Property | Session Token (st_) | User Token (ut_) |
|---|---|---|
| Scope | Tenant only | User + Tenant |
| TTL | 24 hours (fixed) | 5-600 seconds (configurable) |
| Reusable | Until revoked or expired | Consumed on registration finish |
| Use case | All cross-origin flows — identifies tenant | Registration flows — authenticates user cross-origin |
| Carries | tenant_id | user_id, tenant_id, external_id, display_name |
| SDK attribute | <masterkey2-authenticate token="..."> | <masterkey2-register token="..."> |
| Bearer dispatch | Prefix st_ → SessionToken store | Prefix 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_authenticationdoes 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 asAuthorization: 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:
| Priority | Source | Mechanism | When used |
|---|---|---|---|
| 1 | Bearer token | Extract tenant_id from SessionToken or UserToken, look up tenant.rp_id | Cross-origin flows (SDK sends Authorization: Bearer) |
| 2 | Origin header | Parse hostname, match against tenant rp_id values | Same-origin flows (browser sends Origin header) |
| 3 | Global config | RP_ID environment variable | Admin 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):
| Tier | Routes | Default burst | Replenish interval | Rationale |
|---|---|---|---|---|
| Strict | Admin login (POST /, /verify, /admin/passkey-login) | 5 requests | Every 12 seconds | Protects admin OTP and passkey login from brute force |
| Standard | WebAuthn API (/auth/*, /api/qr/*) | 30 requests | Every 2 seconds | Allows normal usage while preventing abuse |
| Service | Server-to-server API (/api/v1/*) | 60 requests | Every 1 second | Higher 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):
-
CORS layer — Applied to API routes. Parses the
Originheader, extracts the hostname, and checks against the whitelist. Only whitelisted origins receive CORS headers. Browsers will block cross-origin responses without valid CORS headers. -
Server-side middleware —
mw_referer_origin_whitelist_checkruns on API routes independently of CORS. Extracts the hostname fromReferer(preferred) orOriginheader, 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):
| Format | Example | Match behavior |
|---|---|---|
| IP address | 192.168.1.1 | Exact IP match |
| Regex | /^10\.1\.1\..*/ | Pattern match against hostname |
| Domain | example.com | Exact hostname match |
Default whitelist: localhost,127.0.0.1.
Session Security
| Property | Implementation |
|---|---|
| Cookie flags | Secure (if HTTPS), SameSite=Lax, HttpOnly (via tower-sessions) |
| Session ID | UUIDv7 (122 bits of entropy) |
| Inactivity timeout | 60 minutes |
| Storage | In-memory (SQLite standalone) or database (PostgreSQL HA) |
| CSRF protection | SameSite=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
| Property | Mechanism |
|---|---|
| Uniqueness | 32 bytes of cryptographic randomness (UUIDv7 for ID, separate random bytes for challenge) |
| Expiry | 5 minutes for standard flows, 10 minutes for cross-device |
| Single use | Atomic SQL: UPDATE SET used=1 WHERE used=0 (checks rows_affected) |
| One-time verify | verified flag prevents verify_auth replay: UPDATE SET verified=1 WHERE verified=0 |
| Type binding | Challenge type (webauthn_login, webauthn_register, cross_device_auth, cross_device_register) is checked on use. A login challenge cannot be used for registration. |
| Tenant binding | Cross-device challenges store tenant_id for RP ID resolution on the mobile device |
| Cleanup | Hourly 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 logincheck_cross_device_auth_status— blocks cross-device loginfinish_cross_device_auth— blocks cross-device loginbegin_registration— blocks new passkey registrationstart_cross_device_register— blocks cross-device registrationverify_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).
| Property | Detail |
|---|---|
| Algorithm | ES256 (ECDSA P-256) |
| Header | {"alg":"ES256","kid":"<tenant_id>"} |
| Claims | sub (external_id), uid (user_id), tid (tenant_id), cid (challenge_id), iat, exp (+60s) |
| Key storage | PKCS#8 DER private key in tenant table. Public key derivable as JWK. |
| Key rotation | POST /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
| Property | Detail |
|---|---|
| Opt-in | Per-tenant proof_forwarding boolean, default false |
| Data forwarded | credentialId, authenticatorData, clientDataJSON, signature (all base64url-encoded) |
| Unforgeable | The authenticator’s ECDSA P-256 private key never leaves the hardware. A compromised server cannot produce a valid signature. |
| Trust boundary | Trust masterkey2 at registration time (for credential public key). After that, every authentication is independently verifiable. |
Verification algorithm
The consuming app:
- Stores credential public keys (JWK) from
GET /api/v1/users/{external_id}/credentialsafter registration - On each
verify-auth, receives theproofobject - Computes
signed_data = authenticator_data || SHA-256(client_data_json)(base64url-decoded values) - Verifies the ECDSA P-256 DER signature over
signed_datausing the stored credential JWK public key
Three-tier security model
| Tier | Protects against | Consuming app does |
|---|---|---|
| No assertion | Nothing | Trust JSON |
| Signed JWT | Network tampering | Verify JWT with public key |
| + Proof forwarding | Compromised server | Store 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:
- Hash commitment — The iframe/mobile computes
SHA-256(challengeId + ":" + prfOutput)and sends only the hash to masterkey2 withfinish_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). - 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).
- CSP hardening — The iframe’s
Content-Security-Policyheader usesframe-ancestors <origin>(not*), restricting which sites can embed it. - Ephemeral hash — The PRF hash commitment is stored ephemerally — in-memory (standalone) or in the
webauthn_challenge.prf_hashcolumn (HA) — and consumed on firstverify_authread.
Threat model
| Threat | Mitigation |
|---|---|
| XSS in consuming app | Hash commitment model — masterkey2 never sees raw PRF output. verify_auth confirms hash match without exposing the value. |
| Compromised masterkey2 server | Attacker has deterministic salt and hash commitment, but not the passkey’s PRF secret (hardware-bound) or the raw PRF output. |
| Clickjacking | frame-ancestors <origin> CSP prevents embedding by unauthorized sites |
| PRF hash replay | Hash commitment consumed on first verify_auth read — second read returns None |
| Callback interception | Consuming 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
| Scenario | Mode | Reason |
|---|---|---|
| Mobile + HTTPS | Passkey | On-device biometric (Face ID, Touch ID, fingerprint) |
| Mobile + HTTP | Unavailable | WebAuthn requires a secure context |
| Desktop + Chromium + HTTPS | Passkey | Chromium’s native WebAuthn dialog includes a cross-device QR option |
| Desktop + Firefox + HTTPS | QR | Firefox lacks a native cross-device QR prompt |
| Desktop + HTTP | QR | WebAuthn unavailable on desktop, but mobile can open masterkey2’s HTTPS URL |
Any + mode="qr" attribute | QR | Manual override (always show QR) |
Graceful degradation
The detection chain ensures the best possible UX for every browser/device combination:
- If WebAuthn is available and the device is mobile, use direct passkey authentication (fastest, most seamless).
- If WebAuthn is available on desktop with Chromium, use the native dialog (which includes its own cross-device QR as a built-in option).
- If WebAuthn is unavailable or the browser lacks cross-device support (Firefox), fall back to a masterkey2-rendered QR code.
- 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:
| Code | Meaning | SDK behavior |
|---|---|---|
user_cancelled | User dismissed the WebAuthn dialog | Auto-retry immediately (QR mode regenerates) |
credential_not_found | No matching passkey for this RP ID | Retry after 3 seconds |
challenge_expired | Challenge TTL exceeded | Regenerate challenge |
rate_limited | HTTP 429 from server | Back off per Retry-After header |
configuration_error | RP ID / Origin mismatch (422) | Show amber config error panel |
server_unreachable | Network error | Show error message |
server_error | 5xx response | Retry after 2 seconds |
webauthn_not_supported | Browser lacks WebAuthn API | Show 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.