REST API reference
For integrators with no JavaScript asset — a native mobile app or a separate single-page app. Every operation performed by the shipped UI and the headless JS API goes through these whitelisted endpoints; this page is the raw contract.
If you can load a script, prefer custom-ui.md — the headless API
handles the base64url encoding, the L3 JSON shapes, CSRF, and the retry contracts for
you. Read security.md and the invariants
before you build; a native app additionally needs mobile-apps.md.
Conventions
- URL:
POST /api/method/<dotted.path>(one endpoint is aGET, noted inline). - Body: JSON. WebAuthn
credentialpayloads follow the WebAuthn Level 3 JSON shapes (PublicKeyCredential.toJSON()/parseCreationOptionsFromJSON/parseRequestOptionsFromJSON);challenge,id/rawId,user.id,userHandle,signature, etc. are base64url. - Success envelope: Frappe wraps a whitelisted dict return as
{"message": <payload>}. Login endpoints that mint a session instead return the core login envelope at the top level (message: "Logged In"for System Users ormessage: "No App"for Website Users, plushome_page). - Typed errors: a refusal carries
exc_type(the exception class name — match on that, never on message text) plus, for the 401 retry contracts, structured keys at the top level of the body. Codes:CeremonyExpired(401),UnknownCredential(401),CredentialNotForThisSite(401 — an existing credential belongs to another RP; never callsignalUnknownCredentialfor it),UVSetupRequired(401),PasskeyConfirmationRequired(401),CeremonyFailed(401 — a signed-in user’s registration, confirmation or password re-auth was refused; the session stays signed in, so the user can retry),BrowserSessionRequired(403 — the request was authenticated by an API key (tokenorBasic) or an OAuth bearer, not a browser session),ImpersonatedSessionRefused(403 — the session is impersonating the user through core’s Impersonate, open to the Administrator and, on v16, to any role granted the Userimpersonatepermission; every passkey write, confirmation and re-auth refuses it, while reads still answer — match thisexc_type, not the message),PasskeyServedByCore(417 — the app has stood down for native core). - Auth / CSRF: authenticated endpoints need a logged-in browser session and
X-Frappe-CSRF-Token: <frappe.csrf_token>on the POST. Guest login endpoints are CSRF-exempt but bound to anHttpOnlypasskey_bindercookie the server sets onbegin_loginand checks on everyverify_*— run the whole ceremony in one first-party browser/client context so the cookie rides along. - Interactive authenticator required: these are ceremony transport endpoints, not a server-to-server authentication API. A client must drive a platform, roaming, or cross-device WebAuthn authenticator and preserve the first-party cookie/session context.
- Rate limits: guest ceremonies are IP-limited (core
@rate_limit); authenticated ones are per-user. Both are listed per endpoint; exceeding one returns429. - Dormant shell: endpoints return
417 PasskeyServedByCoreonly whenfrappe.passkeyadvertises the exact handover markerFRAPPE_PASSKEYS_APP_HANDOVER = "frappe-passkeys-app-handover-v1". Mere module presence blocks fresh installation but does not make an installed app dormant.
Guest — first-factor (passwordless) login
The ceremony is discoverable-credential only: no username is ever sent, so there
is no account-enumeration oracle. begin_login → navigator.credentials.get() on
the client → verify_login.
passkeys.passkey.begin_login
Mint assertion options + a single-use ceremony and set the binder cookie. This is also the
client’s config channel. state_id/options require login_with_passkey and an operational RP.
Source: passkeys/passkey.py:begin_login. Rate limit: 30 / 60 s / IP. Guest.
- Args: none.
- Success
200:{ "message": { "enabled": true, "modes": { "first_factor": true, "second_factor": false }, "state_id": "<opaque>", "options": { "challenge": "<b64url>", "rpId": "example.com", "userVerification": "preferred", "allowCredentials": [], "timeout": 300000 } } }With first-factor mode off,
state_id/optionsand the binder cookie are absent. With no operational RP (no RP or no valid web/app path), returns{"message":{"enabled":false,"modes":{"first_factor":false,"second_factor":false}}}without a binder cookie or Error Log. A valid app-only path remains operational. The binder cookie is set only when assertion options are issued. - Errors:
417served-by-core;429rate limit;AuthenticationErrorfor a request host outside the configured origins when starting a first-factor ceremony.
passkeys.passkey.verify_login
Verify the assertion, resolve the account from its userHandle + credential id,
enforce the user-verification policy, mint the session.
Source: passkeys/passkey.py:verify_login. Rate limit: 10 / 60 s / IP. Guest.
- Args:
state_id(string),credential(thePublicKeyCredential.toJSON()assertion — a JSON object, or a JSON string). - Success
200: the core login envelope at top level —message: "Logged In"for System Users ormessage: "No App"for Website Users, plushome_page(navigate there or reload); the session cookie is set. - Errors (all
401, match onexc_type):CeremonyExpired— thestate_idwas consumed/expired or its RP is no longer current. Callbegin_loginagain and run a freshget()(never re-POST the same assertion).UnknownCredential— the assertion references no live credential (for example, removed).CredentialNotForThisSite— the existing credential was made for another RP. Show the refusal without callingsignalUnknownCredential; it could work again after an RP revert.UVSetupRequired— a UV=1 assertion against a credential that has not completed user-verification setup; the body carries a top-levelsetup_id. Continue withcomplete_uv_setup.AuthenticationError— uniform failure (bad assertion, disabled account, wrong host, …). No detail is given by design.
passkeys.passkey.complete_uv_setup
Finish a passkey whose UVSetupRequired was raised: a one-time password check
authorises the user-verification flip, then the session is minted. Stays available
even under disable_user_pass_login (the password is a factor for the flip, not a
password login). Source: passkeys/passkey.py:complete_uv_setup.
Rate limit: 5 / 300 s / IP. Guest.
- Args:
setup_id(from theUVSetupRequiredbody),pwd(string). - Success
200: the core login envelope (session minted). - Errors:
CeremonyExpired(401, including a missing/changed RP in setup state);AuthenticationError(401, incl. wrong password — throttled at 5 / 900 s / user).
Guest — passkey as a second factor
Active only when passkey_as_second_factor is on. Password first (leg 1), passkey
step-up second (leg 2), speaking Frappe’s own two-factor envelope so core paints the
result natively. These mirror a lot of core’s login() / authenticate_for_2factor;
integrate them only if you are replacing the login page wholesale. Enrolled users get the
passkey step even when core’s should_run_2fa(user) is false. Core’s role/IP coverage
controls OTP for passkey-less users and whether OTP fallback can be offered to enrolled users.
passkeys.passkey.login_with_password
Leg 1: verify the password via core authenticate, then return a passkey assertion
challenge inside core’s verification / tmp_id envelope (or transparently fall back
to core OTP / plain login for users without usable passkeys, including stale-only users). Source:
passkeys/passkey.py:login_with_password. Rate limit: 10 / 60 s / IP. Guest.
- Args:
usr(string),pwd(string). - Success
200: setsverification+tmp_idat the top level (core’s 2FA idiom). Whenverification.method == "Passkey",verification.optionsis the assertion request andverification.fallback.otpsays whether a one-time-code fallback is offered; otherwise it is core’s OTP/SMS/Email envelope, or a plain"Logged In"(System Users) or"No App"(Website Users), withhome_page. - Errors:
AuthenticationError(401, incl. mode-off and bad password — uniform, “wrong password ⇒ no challenge”).
passkeys.passkey.verify_second_factor
Leg 2: verify the step-up assertion, re-authenticate, mint the session. Source:
passkeys/passkey.py:verify_second_factor. Rate limit: 10 / 60 s / IP. Guest.
- Args:
state_id(the leg-1tmp_id),credential(assertion JSON). - Success
200: the core login envelope (session minted). - Errors: on a recoverable failure the
401 CeremonyExpiredbody re-arms — it carries a freshstate_id+verification.options(three attempts total: the initial attempt plus at most two re-arms); their absence means terminal (restart at the app’s password form; use OTP only through the offeredfallback_to_otpflow).
The password leg records a keyed, non-reversible password-hash version and leg 2 compares it again
before minting the session. An RP change returns CeremonyExpired without state_id or
verification (no re-arm).
Password rotation or user disable during the ceremony is terminal and
fails closed. For enrolled users, the final login hook also vetoes alternate/core login paths unless
this app completed the passkey leg or issued the one-time OTP fallback marker.
passkeys.passkey.fallback_to_otp
Abandon the passkey step-up mid-flow and hand off to core’s one-time-code path
(re-checks the knob server-side). Source: passkeys/passkey.py:fallback_to_otp.
Rate limit: 5 / 300 s / IP. Guest.
- Args:
state_id. - Success
200: core’s OTP envelope (verification.methodOTP/SMS/Email). The handoff stores a short-lived marker bound to core’stmp_id; the final login hook consumes it once after core accepts the OTP. A direct core OTP flow has no marker and is vetoed for an enrolled second-factor user. - Complete the handoff: POST
{"otp": "<code>", "tmp_id": "<core tmp_id>"}to/api/method/loginin the same cookie context. Use the newtmp_idfrom the OTP envelope, not the passkeystate_id. Core accepts this POST withoutusr; success returnsmessage: "Logged In"for System Users ormessage: "No App"for Website Users, along withhome_pageand the session cookie. Navigate to the returnedhome_page.
Authenticated — registration (add a passkey)
Sudo-gated: a fresh confirmation (or password re-auth) must have seeded a live
management window. Source: passkeys/api/registration.py.
passkeys.api.registration.begin_registration
Issue creation options + cache the challenge. Rate limit: 20 / 3600 s / user.
- Args:
flow("explicit"default, or"conditional_create"). - Success
200:{"message": {"state_id": "<opaque>", "options": <CreationOptionsJSON>}}. Inject thecredPropsextension intooptionsbeforecreate()so the discoverable state is reported. - Errors:
401 PasskeyConfirmationRequiredwhen no sudo window is live — the body carriesaction("passkeys.manage"),payload_fingerprint, andmethods(⊆["passkey","password"]). Run the confirmation to mint a grant, then retrybegin_registration. AlsoValidationError(passkey_max_per_userreached; passkeys not configured).
passkeys.api.registration.verify_registration
Verify the creation response and persist the WebAuthn Credential. The global
credential_id_sha256 unique index is the final arbiter of duplicate/hijack.
Rate limit: 20 / 3600 s / user.
- Args:
state_id,credential(thecreate()result JSON),label(optional; the DocType sanitises + length-caps it). - Success
200:{ "message": { "name": "<credential docname>", "label": "Laptop", "signal": { "rp_id": "example.com", "user_handle": "<b64url>", "credential_ids": ["<b64url>", "..."], "name": "user@example.com", "display_name": "User Name" } } }The
signalblock feeds the optional WebAuthn Signal API (signalAllAcceptedCredentials/signalCurrentUserDetails); IDs include only usable keys for that response RP. Use it rather than a cached boot RP. - Errors:
401 CeremonyExpired;CeremonyFailed(401 — already registered / could-not-verify; the user stays signed in).
Authenticated — manage credentials
Source: passkeys/api/credentials.py. All resolve identity from the session user —
a name you don’t own returns a uniform DoesNotExistError (no cross-user
oracle).
passkeys.api.credentials.list_credentials
The caller’s own passkeys. A read — not sudo-gated. Rate limit: 60 / 60 s / user.
- Args: none.
- Success
200:{ "message": { "credentials": [ { "name": "…", "label": "…", "enabled": 1, "flagged": 0, "flagged_reason": null, "rp_id": "example.com", "stale": false, "usable": true, "backup_state": 1, "backup_eligible": 1, "discoverable": "Yes", "aaguid": "…", "provider": "Example provider", "last_used_at": "…", "creation": "…" } ], "passkey_only_login": 0, "site_rp_id": "example.com" } }
site_rp_id is a string or null (no operational path). stale means a different/blank stored
RP or no operational site RP; usable means enabled and not stale. Disabled current-RP rows
are not stale. All rows, including stale and disabled ones, remain in the inventory and cap.
The stored passkey_only_login flag is not a statement that its login veto currently applies.
passkeys.api.credentials.rename_credential
Display-only; no sudo. Rate limit: 20 / 3600 s / user.
- Args:
name,label(non-empty). - Success
200:{"message": {"name": "…", "label": "…"}}. - Errors:
ValidationError(empty label).
passkeys.api.credentials.delete_credential
Sudo-gated + last-credential guard. Rate limit: 10 / 3600 s / user.
- Args:
name. - Success
200:{"message": {"deleted": "<name>"}}. - Errors:
401 PasskeyConfirmationRequired(run the confirmation, retry with the grant header);ValidationErrorwhen it would remove the last usable passkey of a passkey-only account or a site with password login disabled (message surfaced verbatim in_server_messages).
Stale removal is allowed by the last-passkey guard; ownership and sudo still apply.
passkeys.api.credentials.set_passkey_only_login
Turn password login off/on for the account. Gated on a passkey grant only — never a password/sudo window (“a password must never disable the password-is-not-sufficient flag”). Enabling additionally needs ≥2 usable passkeys and at least one passkey login mode on. Rate limit: 20 / 3600 s / user.
- Args:
enabled(boolean). - Success
200:{"message": {"passkey_only_login": 1}}. - Errors:
401 PasskeyConfirmationRequiredwithmethods: ["passkey"]and apayload_fingerprintover{"enabled": <bool>}— echo it verbatim intobegin_confirmation(below);ValidationError(<2 usable passkeys or both login modes off).
Authenticated — action confirmation (“passkey signing”)
The re-auth engine that mints the single-use grants the sudo-gated endpoints above
consume, and that any app’s @passkey_protected method uses. Source:
passkeys/confirm.py. Rate limits: 30 / 300 s / user (reauth_password: 5 / 300).
passkeys.confirm.begin_confirmation
Mint UV-required assertion options bound to an action (+ optional payload).
- Args:
action(stable, globally unique action ID), and eitherparams(the raw call args — the server computes the fingerprint) orpayload_hash(a server-issuedpayload_fingerprintechoed verbatim — never compute it client-side). The two are mutually exclusive. - Success
200:{"message": {"state_id": "…", "options": <RequestOptionsJSON>, "payload_fingerprint": "…", "methods": ["passkey"], "action_label": "Release payment", "parameter_summary": [{"label":"Payment","value":"PAY-1"}]}}. With a credential, the defaultmethodslist is["passkey"].passwordis added only when the action setsallow_password_fallback=True;sudoonly when it setsallow_sudo_window=True. The display fields are optional decorator metadata.display_paramsmust be a subset ofbind_params; undeclared values are never exposed and display metadata does not alter grant binding. A protected call publishes this policy to site-scoped Redis before returning its 401, so a subsequent begin/reauth request may land on another worker. Unknown action IDs fail closed and do not offer password fallback.
passkeys.confirm.verify_confirmation
Verify the UV assertion, mint the grant.
- Args:
state_id,credential(assertion JSON). - Success
200:{"message": {"grant": "<opaque token>"}}. Attach it asX-Passkey-Grant: <token>on the retried protected call. - Errors:
CeremonyExpiredfor consumed/expired state or an RP change before grant minting.CeremonyFailedcovers an assertion outside the ceremony allowlist. IfCredentialNotForThisSiteis returned, the confirmation UI treats it as a failed confirmation, with no unknown-credential Signal.
passkeys.confirm.reauth_password
Password fallback: seed a sudo window (bare pwd) or mint an action grant
(pwd + action + payload_fingerprint, only if the action allows password
fallback). Rate limit: 5 / 300 s / user.
- Args:
pwd,action(optional),payload_fingerprint(optional). - Success
200:{"message": {"grant": "…"}}(withaction) or a seeded window.
Confirmation offers passkeys only when the user has usable credentials. Password re-auth is refused under site-wide password disable only while a usable credential exists; a signed-in stale-only user can re-authenticate with a password where the action permits it. This does not permit password sign-in or change a passkey-only switch without a passkey grant.
Authenticated — supporting endpoints
passkeys.passkey.get_signal_data— returns{rp_id, user_handle, credential_ids, name, display_name}for the caller, with IDs filtered to that one RP. Returns null when no operational RP exists. A response with a nulluser_handlealso cannot feed Signals. Never substitutelocation.hostname; the response RP takes precedence over boot. A valid payload withcredential_ids: []is different from null and may signal the empty list. Rate limit: 60 / 60 s / user. Source:passkeys/passkey.py:get_signal_data.passkeys.passkey.record_nudge— folds an enrollment-nudge event ("shown"/"declined"/"opt_out") into per-user cadence state.showncounts toward the prompt cap (send it only after the prompt renders) andopt_outstops future prompts, so the cadence depends on both. Rate limit: 30 / 3600 s / user.passkeys.passkey.record_enforcement— records"defer"or"incapable". A defer consumes at most one grace login per user/session, atomically across tabs/workers, while the server’s current verdict allows grace. Events from exempt, out-of-scope, or already-enrolled users leave enforcement state unchanged. Incapability alerts require the applicable Block + Notify Admin policy. Rate limit: 30 / 3600 s / user.passkeys.passkey.get_app_translations(GET) — the app’s i18n catalog, for rendering the shipped copy on v15/v16 pages. Responses are never cached (private, no-storewhere Frappe exposes response headers; Frappe v15 does not, and the client fetches withno-store), because the request’s language can change without changing the URL. Rate limit: 30 / 60 s / IP.
System Manager — RP settings and stale removal
All endpoints below use POST under
passkeys.passkeys.doctype.passkey_settings.passkey_settings (append the method name).
They require the System Manager role, accept normal administrator API authentication, and
return data under message. Limits are per calling user; exhaustion returns 429.
get_rp_id_change_impact
Read-only preview. 30 / 60 s / user; no sudo required.
- Args:
rp_id(string; blank derives fromhost_name),passkey_originsandpasskey_app_origins(optional newline-separated strings). Omitted/null origins keep saved values; an empty string clears the corresponding explicit list for the preview. - Success:
{current_rp_id, new_rp_id, new_off, stop: {passkeys, users}, start: {passkeys, users}}. RP values may be null;new_offmeans no operational candidate RP. Counts include enabled rows only, with distinct users in each group. Nothing is saved. - Errors:
ValidationErrorfor an invalid RP shape or explicit web/app origin line, including out-of-scope web origins and HTTP outside the developer localhost exception. A valid candidate with no operational path returnsnew_off: trueinstead. This is not a full settings-save validation: a save also validates the derivedhost_nameorigin and requires a web origin.
remove_stale_passkeys
Delete stale, unflagged rows site-wide, including disabled rows. 3 / 60 s / user;
no sudo required. Impersonated sessions receive ImpersonatedSessionRefused (403).
- Args:
site_rp_id(exact operational RP from the settings context you displayed). - Success:
{removed: <count>}. Normal deletion hooks send owner notifications when enabled; flagged rows are kept, including rows flagged while removal waits. Already-removed rows are skipped. Deletion is irreversible. - Errors:
ValidationErrorif the operational RP is null or differs from the argument. Repair configuration or reload before retrying. The server locks settings, reads current RP values from that lock, then deletes through the controllers (settings → handle → credential). This serializes database RP saves, nothost_namefile changes.
Existing settings reads
get_resolved_rp_id(30 / 60 s / user) now returns{rp_id, site_rp_id, configured_site_origin}.rp_idis the resolved configuration value;site_rp_idadditionally requires an operational web/app path.get_settings_context(30 / 60 s / user) addsstale_passkeys: {site_rp_id, passkeys, users, removable}, or null when no operational RP exists. Counts include disabled stale rows;removableexcludes flagged rows.
Boot contract
Desk and portal boot add site_rp_id; credential_count now counts usable keys. Existing rp_id
remains the resolved configuration value. Only System Managers receive rp_alert: null when
both modes are off, otherwise {reason, status, rp_id, site_host, suggested_rp_id, working_origins}.
status is off, app_only or web_works; reason is no_rp_id, host_outside_rp,
no_valid_origins or null. A null reason needs no banner. Origins and the site host come from
configuration, never a caller’s headers. site_rp_id describes operational scope separately from
the login mode switches.
See also
custom-ui.md— the headless JS API (does the encoding/CSRF/retries).mobile-apps.md— native iOS/Android association (well-known files).configuration.md— RP ID / origins the ceremony must run on.security.md— the enforced model, what is trusted, disclosure.