Build your own passkey UI

This app ships a complete, ready-to-use passkey experience — a login-page button, a /passkeys self-service page, and a “Passkeys” section on the User form. You do not need any of this to build your own.

Three integration levels, from least to most work:

You want to… Use Guide
Ship fast, restyle the app’s barebones cards The shipped cards + your CSS Semantic markup contract below
Build your own markup but reuse the ceremony logic The headless JS API (frappe.passkeys.headless) This page
Integrate with no JS asset at all (mobile, SPA) The REST endpoints directly rest-api.md

The headless API is the same code the app’s own portal card engine calls, so your custom UI runs the exact, tested ceremony path the shipped UI does. It carries no markup and no styling — you supply the DOM, it does the WebAuthn + server dance.


The public JavaScript surface

Everything below is published on the frappe global. The lifecycle API lives under frappe.passkeys.headless; the two pure libraries under frappe.passkeys_* are the building blocks it (and you, if you go lower-level) are built on.

Global What it is
frappe.passkeys.headless The markup-free lifecycle API (below).
frappe.passkeys.confirm(action, params) Re-auth (“passkey signing”) — resolve a single-use grant for a sensitive action.
frappe.passkeys.call(method, args) Call a @passkey_protected method, running the confirmation on demand.
frappe.passkeys.manage The Desk-only card renderers (renderCards, openManagerDialog, addPasskey, …). Not needed for a custom UI.
frappe.passkeys_common Pure helpers: detectCapabilities, base64url, the L3 JSON shim, mapDomException, LOGIN_STATES, createConfirmEngine, …
frappe.passkeys_manage_common Pure helpers: MANAGE_METHODS, credentialViewModel, providerFor, the nudge/enforcement decisions.

frappe.ui.passkey.headless / .confirm / .call are forward-compatible aliases (the intended core namespace).

frappe.passkeys.headless

Method Resolves Notes
detectCapabilities() {supported, conditionalMediation, uvpaa, hybrid} null fields mean unknown — never coerce to false.
login(opts?) a structured result (below) First-factor discoverable sign-in: begin → get() → verify. opts.mediation / opts.signal for autofill.
beginLogin() {enabled, modes, stateId, options} Lower-level: fetch fresh options (also the config channel).
completeUvSetup(setupId, password) a structured login result Finish uv_setup_required with the returned setupId and a password collected by your UI.
verifyLogin(stateId, assertion) a structured result (below) Lower-level: verify an assertion you ran yourself.
register(opts?) {name, label, signal} Add a passkey: begin (+ re-auth if needed) → create() → verify. opts.label, opts.flow.
listCredentials() {credentials, passkey_only_login} The caller’s own passkeys. Not sudo-gated.
renameCredential(name, label) {name, label} Display-only, no re-auth.
removeCredential(name) {deleted} Sudo-gated — runs the confirmation engine.
setPasswordlessOnly(enabled) {passkey_only_login} Turn password login off/on for the account. Passkey re-auth only.
confirm(action, params) / call(method, args) grant / result Proxies to frappe.passkeys.confirm / .call.

Login result shape — login() and verifyLogin() never reject on a server refusal or a transport failure; they resolve one of:

{ ok: true,  redirect }                                   // signed in — navigate to redirect (or reload)
{ ok: false, reason: "disabled" }                         // no first-factor mode / unconfigured
{ ok: false, reason: "gesture", code, message, statusState }   // the browser get() failed/was cancelled
{ ok: false, reason: "server", kind, message, setupId?, statusState } // typed server refusal
{ ok: false, reason: "network" }                          // transport failure

code is the fixed DOMException taxonomy (user_cancelled, not_supported, …), kind the typed-error taxonomy (ceremony_expired, unknown_credential, uv_setup_required, …). statusState maps to the shipped LOGIN_STATES copy if you want to reuse it. All failure copy is deliberately generic — the server never reveals whether an account or credential exists, and neither should your UI.

Registration / management errors — register, renameCredential, removeCredential reject an Error whose .code is one of already_registered, user_cancelled, add_expired, add_failed, not_supported, confirmation_unavailable, list_failed, rename_failed. .message is a human-readable string (the server’s own message when it sent one — e.g. the last-passkey delete guard — surfaced verbatim).

conditionalMediation reads the browser’s getClientCapabilities().conditionalGet key, with isConditionalMediationAvailable() as a fallback when the key is absent.

Loading the assets

Serve these from the app’s public tree (order matters — each reads the one before):

<script src="/assets/passkeys/js/passkey_common.bundle.js"></script>          <!-- always -->
<script src="/assets/passkeys/js/passkey_manage_common.bundle.js"></script>   <!-- for management -->
<script src="/assets/passkeys/js/passkey_headless.bundle.js"></script>        <!-- the API -->

Every library ships as a *.bundle.js file. Esbuild content-hashes each one at bench build, so on a Frappe-rendered page you should register the bare bundle name (no /assets prefix) — via web_include_js on a website page, or app_include_js in your hooks.py on Desk — and Frappe’s bundled_asset() rewrites it to the hashed, cache-busted URL. The raw /assets/passkeys/js/…bundle.js paths above are the un-hashed physical fallback for a hand-rolled page Frappe doesn’t render; they load, but the browser may cache them across a deploy, so prefer the bare-name registration when you can.

A login-only page needs just passkey_common.bundle.js + passkey_headless.bundle.js. On Desk and on authenticated website pages with a passkey mode enabled, these are already loaded. The portal shim also supplies frappe.boot.passkeys and runs the stock enrollment interstitial or nudge, including on custom www pages. You do not need a second gate; removing passkey_portal.bundle.js removes that enrollment UX, not the server’s login checks.

On a custom authenticated www page that makes POSTs during startup, bridge CSRF before those scripts run; core’s generic token injection follows colocated JavaScript. Use the same pattern as /passkeys, with an uncached page controller:

import frappe
from frappe.sessions import get_csrf_token

no_cache = 1

def get_context(context):
    if frappe.session.user == "Guest":
        frappe.local.flags.redirect_location = "/login"
        raise frappe.Redirect
    context.csrf_token = get_csrf_token()

In the template’s page_content block, before scripts that make requests:

<script>
  window.frappe = window.frappe || {};
  frappe.csrf_token = ;
</script>

Example: a custom login page

Your own markup, your own styles. The only app dependency is the two scripts.

<form id="signin">
  <label>Email <input id="email" autocomplete="username webauthn"></label>
  <label>Password <input id="password" type="password" autocomplete="current-password"></label>
  <button id="pw-signin">Sign in</button>
  <button type="button" id="passkey-signin">Sign in with a passkey</button>
  <p id="status" role="status" aria-live="polite"></p>
</form>

<script src="/assets/passkeys/js/passkey_common.bundle.js"></script>
<script src="/assets/passkeys/js/passkey_headless.bundle.js"></script>
<script>
  const pk = frappe.passkeys.headless;
  const status = document.getElementById("status");

  document.getElementById("passkey-signin").addEventListener("click", async () => {
    const caps = await pk.detectCapabilities();
    if (!caps.supported) { status.textContent = "This device can't use passkeys."; return; }

    status.textContent = "Waiting for your device…";
    const r = await pk.login();                 // begin → navigator.credentials.get() → verify
    if (r.ok) { window.location.assign(r.redirect || "/app"); return; }

    // One generic message per outcome — never leak whether an account exists.
    status.textContent =
      r.reason === "disabled"                  ? "Passkeys aren't available here." :
      r.reason === "network"                   ? "Couldn't reach the server — try again." :
      r.code === "not_supported"               ? "This device can't use passkeys." :
      r.kind === "unknown_credential"          ? "That passkey isn't recognised here — sign in another way." :
      /* cancelled / timed out / anything else */ "Couldn't sign in with a passkey — try another way.";
  });
</script>

Autofill (conditional UI) is opt-in. Give the username field autocomplete="username webauthn" (above) and, on page load, run a silent conditional ceremony you can cancel when the user starts typing a password:

const ac = new AbortController();
pk.login({ mediation: "conditional", signal: ac.signal }).then((r) => {
  if (r.ok) window.location.assign(r.redirect || "/app");
  else if (r.kind === "uv_setup_required") {
    status.textContent = "Sign in with your password to finish passkey setup.";
    // Collect a password, then call pk.completeUvSetup(r.setupId, password).
    // Handle its structured result before navigating; no session exists yet.
  }
});
// call ac.abort() before starting any explicit passkey/password flow

Handle uv_setup_required in the explicit button flow too. For a replacement password form on a second-factor site, use login_with_password → verify_second_factor, or the offered OTP flow; see the REST contract.


Example: a custom “manage passkeys” page

<ul id="cards"></ul>
<button id="add">Add a passkey</button>
<label><input type="checkbox" id="passwordless"> Passwordless login only</label>
<p id="msg" role="status" aria-live="polite"></p>

<script src="/assets/passkeys/js/passkey_common.bundle.js"></script>
<script src="/assets/passkeys/js/passkey_manage_common.bundle.js"></script>
<script src="/assets/passkeys/js/passkey_headless.bundle.js"></script>
<script>
  const pk = frappe.passkeys.headless;
  const M  = frappe.passkeys_manage_common;   // optional: card view-models + copy
  const cards = document.getElementById("cards");
  const msg = document.getElementById("msg");

  async function render() {
    const { credentials, passkey_only_login } = await pk.listCredentials();
    document.getElementById("passwordless").checked = !!passkey_only_login;
    cards.innerHTML = "";
    for (const cred of credentials) {
      const vm = M.credentialViewModel(cred);          // label, provider, Synced/Device-bound, dates
      const li = document.createElement("li");
      li.textContent = vm.label + (vm.badge.synced ? " · Synced" : " · Device-bound");
      // …your own Rename / Remove buttons, wired to the calls below…
      cards.appendChild(li);
    }
  }

  document.getElementById("add").addEventListener("click", async () => {
    try { await pk.register(); msg.textContent = "Passkey added."; render(); }
    catch (e) {
      if (e.code === "user_cancelled") return;
      msg.textContent = e.code === "already_registered"
        ? "This device already has a passkey for this account." : e.message;
    }
  });

  async function rename(name, label) { await pk.renameCredential(name, label); render(); }

  async function remove(name) {
    try { await pk.removeCredential(name); msg.textContent = "Passkey removed."; render(); }
    catch (e) { if (e.code !== "user_cancelled") msg.textContent = e.message; } // e.g. the last-passkey guard
  }

  document.getElementById("passwordless").addEventListener("change", async (ev) => {
    try { await pk.setPasswordlessOnly(ev.target.checked); render(); }
    catch (e) { ev.target.checked = !ev.target.checked; msg.textContent = e.message; }
  });

  render();
</script>

listCredentials() returns all the caller’s rows, including disabled ones; the raw fields are listed in the REST reference. credentialViewModel(cred) returns name, label, providerName (null if unknown), hasProvider, unknownProviderKey, badge: {synced, key, hintKey}, enabled, rpId, stale, usable, flagged, flaggedReason, discoverable, created, lastUsed, and a11y: {rename, del}.

Enabling Passwordless login only needs at least two usable passkeys and an active passkey login mode. Use those checks to explain a disabled switch; the server enforces both. Removal is limited to 10 requests/hour/user. Its 429, like an action validation refusal, currently rejects with confirmation_failed; show the error message without retrying in a loop.

Removing a passkey needs a confirmation engine

Removing a passkey is sudo-gated. Turning passkey-only login on or off needs a passkey grant, never a password or a sudo window. Either call may return HTTP 401 PasskeyConfirmationRequired. Both route through frappe.passkeys.call, which runs that confirmation and retries. On Desk and on the app’s portal pages frappe.passkeys.call is already wired. On a bare page you wire it once from the pure engine and your own tiny modal. register() also uses this engine when its sudo window is absent; without it, registration rejects confirmation_unavailable. Its re-auth UI runs inside the register() promise.

The guarded assignments below retain any installed engine. On an authenticated www page the portal bundle normally installs one first, so these keep its stock modal. To use your own modal, assign both methods without || after the portal bundle loads:

const C = frappe.passkeys_common;
const engine = C.createConfirmEngine({
  post: (method, body, headers) => fetch("/api/method/" + method, {
    method: "POST", credentials: "same-origin",
    headers: { "Content-Type": "application/json", "X-Frappe-CSRF-Token": frappe.csrf_token, ...headers },
    body: JSON.stringify(body || {}),
  }).then((r) => r.json().then((body) => ({ ok: r.ok, status: r.status, body }))),
  runGesture: (opts) => navigator.credentials
    .get({ publicKey: C.parseRequestOptionsFromJSON(opts) })
    .then((cred) => C.authAssertionToJSON(cred)),
  ui: makeYourConfirmModal,   // your DOM; see the controller contract below
});
frappe.passkeys = frappe.passkeys || {};
frappe.passkeys.confirm = frappe.passkeys.confirm || engine.confirm;
frappe.passkeys.call = frappe.passkeys.call || engine.call;

ui is a controller object, or a function returning a fresh one per confirmation. The engine calls a supplied factory once per ceremony; clean up listeners in done when reusing DOM. Implement all six methods:

Method Called when
chooseMethod({action, actionLabel, parameterSummary, canPasskey, canPassword}) A passkey is available (password may also be available). Resolve "passkey" or "password"; reject to cancel.
collectPassword({action, actionLabel, parameterSummary}) The password leg starts, and again after each wrong password. Resolve the password; reject to cancel.
passwordError(message) The password was wrong and the engine is about to call collectPassword again. Show message on the retry prompt.
announce(message) Status for screen readers (for example “Waiting for your passkey…”).
busy(isBusy) The passkey gesture is in progress.
done(isOk) The confirmation finished or failed. Close your modal here.

With only password available, the engine goes straight to collectPassword.

passkey_portal.bundle.js is a full, self-contained reference implementation of makeYourConfirmModal (a role="dialog" overlay with focus trap and Esc handling).

Action confirmation for your own methods

The same engine gates any of your whitelisted methods. The optional display_label and display_params decorator arguments provide translated, human-readable confirmation context:

@frappe.whitelist(methods=["POST"])
@passkey_protected(
    action="myapp.release_payment",
    bind_params=["payment_id", "amount"],
    display_label="Release payment",
    display_params={"payment_id": "Payment", "amount": "Amount"},
)
def release_payment(payment_id, amount):
    ...

Only the arguments named in bind_params are bound to the grant: a named parameter binds its value (defaults applied), naming the *args / **kwargs parameter binds the whole tuple / mapping, and any other name binds that **kwargs key only when the call passes it. A protected action requires a passkey by default; set allow_password_fallback=True to also let a user confirm with their password. allow_sudo_window defaults to false; set it true to let a live management sudo window satisfy the gate with no new gesture.

display_params must be a subset of bind_params, and every bind_params name must be a parameter of the method (or reach its **kwargs) — decoration raises ValueError otherwise. A call that does not bind to the signature (missing or unexpected arguments), or whose bound name also arrives as a **kwargs key, is refused with PasskeyConfirmationRequired and no payload fingerprint, before any grant is consumed. Undeclared arguments are never returned to the client. The server emits action_label and parameter_summary, translating labels and rendering booleans/nulls as safe display values. The 401’s top-level parameter_summary is an array of {label, value} rows (for example [{label: "Amount", value: "250"}]), passed to the controller as parameterSummary; render it as text, never HTML. These fields are presentation only: the grant remains bound to the canonical server-side payload fingerprint. Call the method with:

await frappe.passkeys.call("myapp.api.release_payment", { payment_id, amount });
// or resolve a grant yourself:
const grant = await frappe.passkeys.confirm("myapp.release_payment", { payment_id, amount });

Prefer call() for decorated third-party methods. Its initial 401 publishes the action policy to site-scoped Redis, so the confirmation and password-fallback requests behave identically if they land on another worker. A direct confirm() is appropriate when the action policy is eagerly loaded on every worker; an unknown action deliberately fails closed without a password fallback.

call() can reject with user_cancelled, not_supported, no_credentials, confirmation_failed, fallback_unavailable, or network (the confirmation engine’s codes). fallback_unavailable means neither a passkey nor password confirmation is available. confirmation_failed also covers a protected method’s refusal after successful confirmation and a server’s refusal to start one (for example an impersonated session); show .message. Expired confirmations say “That took too long — please try again.”

For server tests, follow the enrollment, request, confirmation and grant helpers in test_confirm_api.py, using SoftAuthenticator to produce a UV assertion for begin_confirmation → verify_confirmation. Present the returned grant in X-Passkey-Grant (or frappe.local.form_dict["_passkey_grant"]) in the same browser-session fixture before calling your decorated method. Test refusal without a grant and after reuse; these helpers run real verification and are test fixtures, not a production bypass.


Semantic markup contract

If you would rather embed the app’s barebones cards and only restyle them, every element the renderers emit carries a stable, semantic class hook and no opinionated styling beyond what already ships (all colours are Frappe CSS variables). Style these; do not depend on the DOM nesting staying identical.

Cards (renderCards / the portal /passkeys list):

Hook Element
.passkey-cards-root, .passkey-card-list list container / <ul role="list">
.passkey-card, .passkey-card-disabled a credential row (disabled variant)
.passkey-card-stale, .passkey-stale-note stale row and its “Made for …” note; can coexist with Disabled or Flagged
.passkey-card-glyph leading icon slot
.passkey-card-label, .passkey-card-labelrow the credential name
.indicator-pill (green / blue / gray) Synced / Device-bound / Disabled pills; the label is the pill’s text
.passkey-card-meta, .passkey-card-provider, .passkey-card-created, .passkey-card-lastused provider + dates
.alert.alert-danger the security-flagged notice (role="alert")
.passkey-card-actions, .passkey-icon-btn the Rename / Remove buttons
.passkey-card-add-row, .passkey-btn the “Add a passkey” row
.no-result the zero-passkeys empty state (centered muted text plus the add button)
.passkey-only-row, .passkey-only-label, .passkey-only-help, .passkey-only-toggle the passwordless switch
.passkey-impersonated, .passkey-impersonated-notice the read-only cards an impersonated session sees (no add, rename, remove or switch)

Use list_credentials’ server stale and usable booleans and site_rp_id. A disabled current-RP credential is not stale; a blank stored RP is stale. Only usable keys count toward the passkey-only switch’s two-key enable threshold. The management view model exposes rpId, stale and usable; admin inventory fallback needs siteRpId from boot.

staleNotice(payload) returns staleNudgeBody when stale keys exist but none are usable, or siteOffNotice when site_rp_id is null. The shipped prompt uses .alert.alert-info; Add is hidden while the site is off. Stale removal still needs ownership and management sudo.

For Signals use frappe.boot.passkeys.site_rp_id, never location.hostname. The response’s rp_id wins over boot; null/missing signal data means no call, while a valid payload with an empty ID list still signals that list. Map CredentialNotForThisSite to credential_not_for_site (a failed confirmation in the confirmation UI), never unknown_credential or signalUnknownCredential.

Login button + status (passkey_login.bundle.js): .btn-passkey-login, .passkey-glyph, .passkey-label, and the status line .passkey-status (--progress / --success / --error tone modifiers, __icon / __text parts).

Nudge banner: #passkey-portal-nudge (alert alert-info).

For your own enrollment UI, call frappe.passkeys_manage_common.enforcementDecision( frappe.boot.passkeys, caps) with caps from detectCapabilities(). It returns {show, blocking, variant, notifyAdmin, graceRemaining, reason}. show alone does not mean blocking: variant: "nudge" is non-blocking; variant: "enforce" uses blocking to distinguish the required gate from grace. notifyAdmin requests an incapable report through record_enforcement; the server validates it.

frappe.boot.passkeys.impersonated is true while the session impersonates the user through core’s Impersonate (the Administrator, or on v16 any role granted the User impersonate permission). enforcementDecision, nudgeDecision and upsellDecision then never prompt (reason: "impersonated"), and a custom UI should hide its add, rename, remove and passwordless controls (frappe.passkeys_manage_common.isImpersonated(boot)). The server refuses every passkey change and confirmation from such a session with ImpersonatedSessionRefused (403) regardless; mapServerExcType maps it to impersonated_session, and the server’s message says why.

The visually-hidden live region (.passkey-sr-only) backs every announcement — never display:none it; screen-reader users depend on it.


Security invariants an integrator must not break

WebAuthn’s security comes from the browser binding each assertion to the exact origin that requested it. A custom UI keeps that intact by holding these lines:

  1. Serve over HTTPS. WebAuthn only runs in a secure context (localhost is the one dev exception). Plain http:// on any real host silently disables everything.
  2. Run the ceremony on the RP’s own origin — never proxy or iframe it cross-origin. begin → navigator.credentials.get/create → verify must all happen on an origin that matches the site’s configured RP ID / allowed origins (see configuration.md). Relaying a ceremony through another origin either fails closed or is the phishing attack the technology exists to stop.
  3. Never cache, store, or replay a challenge. begin_* responses are single-use and short-lived; the server consumes each one atomically. Always fetch fresh options for every attempt — never reuse state_id/options, and never re-POST an assertion (retry means a new ceremony).
  4. Keep requests same-origin and CSRF-safe. Send credentials: "same-origin" and attach X-Frappe-CSRF-Token: frappe.csrf_token on authenticated POSTs (the headless transport does both for you). Guest login endpoints are CSRF-exempt but protected by an HttpOnly binder cookie the server sets and checks — so run the whole guest ceremony in the same browser context, first party.
  5. Don’t invent existence signals. Surface the generic failure copy; the server deliberately returns uniform errors so an attacker can’t enumerate accounts or credentials. Do not branch your visible copy on anything that could reveal one.
  6. Let the server be the authority. Client checks (capability, credential counts, and the like) are for UX only; every rule is enforced server-side. Trust its verdict, don’t reimplement it.

See also