Security model (operator’s view)

What this app enforces, what it trusts, and what residual risk remains. This is a deployment-level description, not the internal design record.

What the app enforces

The whole ceremony is server-side and browser-bound. Challenges are CSPRNG-generated, stored server-side, and matched against exactly one server record scoped to a single ceremony type — a login challenge can never satisfy a registration or a confirmation. The challenge TTL tracks the client timeout (≈300 s for login/registration, 180 s for action confirmation).

Single-use challenges, fail-closed. Each ceremony state is consumed atomically on the first verify attempt (exactly one caller wins across workers). A consumed, expired, or evicted state yields a uniform typed error, never a silent pass. A wrong passkey at the second factor re-arms a fresh state up to a small cap rather than burning the only 2FA state.

This uniform-error contract assumes System Settings → Allow Error Traceback is off. Frappe v15 ships it defaulted on; production sites must disable it or every 401 includes the traceback.

Guest login-CSRF defence (binder cookie). Because the attacker in a login-CSRF is the ceremony’s own legitimate starter, a browser-bound token is the only defence. Guest ceremonies set an ephemeral Secure; HttpOnly; SameSite=Lax cookie and store only its SHA-256 in the record; verify fails closed unless the request carries the matching cookie. An attacker cannot set or read this HttpOnly cookie cross-site. Authenticated ceremonies bind to the session id instead.

Origin and RP-ID pinning, exact-match, fail-closed. RP ID and origins are resolved from administrator-controlled configuration — never from live Host or X-Forwarded-* headers. Origins are an exact-match allowlist including port; the library checks the assertion’s origin against that list. A compatible host_name contributes its own exact origin and explicit Passkey Origins add to it; RP ID alone never authorizes https://<rp_id>. Settings saves require a non-empty resolved web-origin set. iOS must therefore have its asserted https://<rp_id> origin explicitly present when the site’s host_name does not contribute that exact value. The request host is re-checked at both begin and complete.

Usable means enabled and made for the operational RP. A different or blank credential RP ID is stale; a disabled current-RP row is not stale. The operational RP is null when no RP resolves or no canonical in-scope web origin or valid Trusted App Origin remains. This decision uses only settings and host_name, never request headers or form parameters. Runtime web-origin checks accept canonical HTTP or HTTPS origins in RP scope; HTTPS remains the settings-save rule (with the developer localhost exception). An HTTP path counting as operational does not make it a browser secure context.

When no operational path exists, passkey login discovery is disabled without an Error Log, registration and passkey confirmation refuse, enrollment enforcement/nudges pause, and no WebAuthn Signals are sent. Passkey-only and passkey-second-factor vetoes stop applying to users without usable keys, including the OAuth password-grant guard. Core disable_user_pass_login still applies. If a valid app path survives, the RP stays operational and the vetoes remain for users with usable keys, even when the configured website has no passkey path. These are administrator-controlled changes, exposed through the manager’s banner and posture panel.

Ceremonies re-check their recorded RP before credential insertion or session/grant minting; missing or changed RP returns CeremonyExpired. Second-factor verification checks before re-arming too, so an RP change yields no retry state. Discoverable login with an existing credential made for another RP returns CredentialNotForThisSite, never a browser signal to forget an unknown credential. See the concurrency limits below.

Cross-origin rejection is enforced by the app. py_webauthn 2.8 accepts crossOrigin: true and never parses topOrigin, so the app parses the raw client data itself and refuses any cross-origin ceremony. This is the sole enforcement of the “no cross-origin iframe ceremonies” rule.

User-verification policy, per ceremony. A passwordless first-factor login completes only when the authenticator actually verified the user (UV bit set) and that credential’s UV was initialized with a second factor — a bare UV assertion against an uninitialized credential is routed to a one-time password-backed setup step, never straight to a session. A passkey action confirmation always requires UV (a password confirmation, where the action allows one, is a separate method). A second-factor assertion after a correct password does not: the password already proved knowledge, and the passkey adds possession. UV is read from the assertion, never assumed.

Sign-count and backup-flag policy. Counters are stored and checked app-side: an exact non-zero replay is always rejected; a regression is flagged (owner emailed on the first flag) or hard-rejected under the Hard-fail knob; the counter is never written downward. Backup-eligibility is write-once at registration — a later mismatch fails the ceremony.

Global credential uniqueness. credential_id_sha256 is a unique index; duplicate registration and cross-account credential hijack both fail closed at insert.

Assertions must belong to the expected user. A passwordless login resolves the account from the assertion’s userHandle and requires the credential to belong to it. When the user is known before the ceremony (the second factor, action confirmation), the credential must be in that ceremony’s allow-list and owned by the user, and a returned userHandle must be that user’s handle (WebAuthn L3 §7.2).

Session minting through one choke point. Every passkey session is created via core’s login_as → post_login — full login hooks, IP/hour checks, a fresh session and CSRF token, and an Activity Log row. The app never spins up a fresh login manager mid-flow.

Second-factor enforcement covers alternate login paths. When Passkey as Second Factor is on and a user has a usable credential, the final on_login hook vetoes password, email-link, social/OAuth, LDAP, and other core login completions unless this app has completed the passkey step. The only downgrade is an enabled OTP fallback initiated by this app: fallback_to_otp issues a short-lived, one-time marker bound to core’s tmp_id, and the hook consumes it only on core’s login route after core accepts a non-empty OTP. Carrying that tmp_id on an email-link or OAuth request does not consume or satisfy it. Calling core’s OTP path directly creates no marker and remains blocked.

Administrator exemption is narrow. Administrator remains break-glass exempt from the per-user Passkey Only Login veto. Administrator is not exempt from the enrolled second-factor rule: once a usable credential is explicitly enrolled while Passkey as Second Factor is active, the same alternate-path veto applies. Core’s site-wide disable_user_pass_login also has no Administrator exemption. Passkey enrollment enforcement likewise keeps System Manager users in scope by default. A System Manager handles enrollment recovery on the affected User → Passkeys section, using a grace reset or temporary per-user exemption. If no manager can sign in, restore one trusted manager before considering site-wide changes; see recovery.

Hardening Administrator

Frappe core has no switch that disables Administrator’s password. Administrator is break-glass: the per-user “Passkey Only Login” flag does not apply, and a password still works while site-wide password login is on and Administrator has no usable passkey under Passkey as Second Factor.

Use named System Manager accounts day to day. Enrol a passkey for Administrator with Passkey as Second Factor on. Restrict Administrator by IP (User → Restrict IP). Set notify_admin_access_to_system_manager in site config so System Managers are emailed when Administrator logs in. Keep a strong, vaulted password. Recovery is in recovery.md.

Password rotation is checked at the session boundary. Every password-to-passkey ceremony stores a keyed, non-reversible version of the password hash at leg one and compares it with the current version before minting a session. A password change during the ceremony fails closed. The marker does not expose the password hash. The plaintext password rides the Redis ceremony record (TTL 300 s) on the second-factor leg only when passkey_2fa_allow_otp_fallback=1 (the default) and the user is OTP-capable, matching core’s cache_2fa_data, or for an external-auth user without a local password hash because core must re-authenticate it before session minting. Mode enablement and local-password ceremonies use Frappe core’s get_encryption_key(), which creates and saves a key when site_config.json has none; an empty encryption_key entry fails closed.

Password-reset keys rotate a password but do not satisfy a passkey factor. Frappe core calls login_as after a successful reset. For an enrolled second-factor user, the app lets the reset transaction finish but downgrades that automatic login to Guest; the user must then sign in with the new password and passkey (or an explicitly enabled OTP fallback). Passkey-only accounts keep their stricter veto.

Passkey failures share core’s consecutive-login tracker. Once a credential resolves an account, invalid assertions feed Frappe’s tracker. A locked account cannot bypass that lock with a valid passkey, and a successful verified passkey resets the same consecutive-failure state.

Action-confirmation grants are tightly bound. A grant from the @passkey_protected primitive is single-use, ~180 s, and bound to user + session + action + payload. Before any grant is read, the call is bound to the full signature; a call that would not bind (a missing or unexpected argument) is refused and no grant is consumed. The payload holds each name in bind_params: a named parameter binds its value (defaults applied), a *args / **kwargs parameter binds the whole tuple / mapping, and any other name binds that key of the **kwargs mapping only when the call passes it, so pay() and pay(amount=None) never share a grant. A bound name that also arrives as a **kwargs key (a positional-only, *args or **kwargs name passed by keyword) is ambiguous and refused. Arguments left out of bind_params are not bound. A protected action requires a passkey by default; a password confirmation is accepted only when the action opts in with allow_password_fallback=True. allow_sudo_window=True (off by default) lets a live full-sudo window satisfy the gate without a new gesture. Tokens are returned once and stored only as SHA-256, so a cache snapshot yields nothing usable. The grant is consumed before the protected function runs (one gesture = one attempt), and the payload hash is always computed server-side with a pinned canonicalization — the client never computes a hash. A presented grant is consumed before checking its payload binding: a mismatched payload spends the token and cannot authorize the action through that grant. A live sudo window can still authorize it when allow_sudo_window=True; otherwise, confirm again before retrying. The signature-validation refusals described above happen before consumption. The action↔challenge binding replaces the retired txAuthSimple extension: the signature commits to a challenge that names exactly one action and payload.

The decorator publishes its static policy to site-scoped Redis before returning the initial 401, so begin_confirmation and password re-auth can land on another worker without changing the offered methods or display metadata. Unknown actions fail closed without a password fallback; the protected method’s consumer remains the final method and payload authority.

The “sudo” re-auth window (default 600 s) gates the app’s own management surface (add/delete passkeys). It is seeded by a fresh interactive login or a password / passkey re-auth. An “external” login (email link, social) seeds only the restricted first-passkey bootstrap when passwordless passkey login is enabled, never general management power.

An impersonated session is read-only for passkeys. Core’s “Impersonate” is open to the Administrator and, on Frappe v16, to any role granted the User impersonate permission. The lock stops accidental or hidden changes to the user’s passkeys from that session. It is not a boundary against a determined administrator, who can do the same from their own session. Reads still answer.

Not covered, by design:

Password classification and OTP-fallback acceptance follow command dispatch precedence. A truthy cmd must be exactly login; only a request without a diverting command may rely on the canonical /api/method/login path. Redeeming an email-link key through that URL remains an external login and cannot spend an OTP fallback marker.

Security invariants are transactionally locked. Authentication locks the user and credential before counter/UV updates; registration locks the user, handle, and credential census before cap enforcement and insertion; credential deletion and passkey-only toggles share a locked login-floor census; and mode-setting changes lock the Single rows before checking passkey-only users. The Passkey Settings and System Settings floor validators (second factor needs core 2FA; second factor cannot be the only passkey mode while password login is off) lock the Passkey Settings rows and read the other document’s values with locking reads. Frappe locks each Single’s own rows before validate, so two such saves at the same moment can meet in opposite order; InnoDB then aborts one as a deadlock and the admin retries it. This prevents concurrent workers from committing individually valid reads into an invalid combined state.

Enrollment grace is once per session. A user/session digest is claimed atomically in Redis, so retries or multiple tabs can consume at most one grace defer for that session.

Uninstall exports fail closed. Credential export schema v2 is site-bound and authenticated with HMAC-SHA256 derived from the site’s encryption_key, written atomically at mode 0600. Import rejects another site, a bad signature or an unsigned file. Its default requires empty passkey tables; allow_existing=True is an explicit reviewed merge with row-level rejection reporting.

Enrollment cadence is server-owned. Nudge events and grace deferrals lock the User row before reading the current DefaultValue row with a locking read, bypassing cached values and stale transaction snapshots. Concurrent nudge events preserve opt-out and deferrals preserve spent grace. Degrade to Nudge honors the same opt-out, cap and cooldown on Desk and portal; renaming a User preserves these budgets.

Native dormancy requires an explicit contract. Fresh installation is blocked by any frappe.passkey module to avoid installing two authorities. An already-installed app no-ops hooks and returns 417 PasskeyServedByCore only when core defines the exact marker FRAPPE_PASSKEYS_APP_HANDOVER = "frappe-passkeys-app-handover-v1". Module presence alone is not accepted as evidence of a complete or safe handover.

Password-oracle throttling on the password-taking endpoints the app adds beyond core’s login surface (the uv-setup step and the confirmation password fallback): a per-user failure counter locks after 5 failures for 15 minutes, independent of core’s login tracker. The primary login leg keeps core’s own protections only, to avoid an account-lockout denial of service.

Rate limits, keyed two ways. Pre-session (guest) endpoints keep core’s own @rate_limit decorator, which keys on IP — the only stable identity before a session exists. Every authenticated endpoint instead uses a per-user app counter (a session-user-keyed cache token), because core’s IP keying would 429 a whole NAT’d office off one busy user and can’t attribute abuse to an account. A guest ceremony can never reach the per-user endpoints, so the two classes don’t overlap. Authenticated endpoints check the session (and refuse an impersonated session’s writes) before they count a call, so an unauthenticated request or an impersonated write never spends a user’s budget; the counter still runs before any single-use state is consumed. complete_uv_setup re-checks that passwordless login is on before it spends a password attempt.

Guest / pre-session endpoints — core @rate_limit, IP-keyed:

Endpoint Limit
begin_login 30 / 60 s
verify_login 10 / 60 s
complete_uv_setup 5 / 300 s
login_with_password 10 / 60 s
verify_second_factor 10 / 60 s
fallback_to_otp 5 / 300 s
get_app_translations 30 / 60 s
assetlinks / apple_app_site_association 120 / 60 s

Authenticated endpoints — per-user app counter, keyed on the session user:

Endpoint Limit
begin_registration 20 / hour
verify_registration 20 / hour
list_credentials 60 / min
rename_credential 20 / hour
delete_credential 10 / hour
set_passkey_only_login 20 / hour
get_signal_data 60 / min
record_nudge 30 / hour
record_enforcement 30 / hour
begin_confirmation 30 / 5 min
verify_confirmation 30 / 5 min
reauth_password 5 / 5 min
get_resolved_rp_id 30 / min
get_security_posture 30 / min
get_settings_context 30 / min
recalculate_enforcement_preview 3 / min
get_rp_id_change_impact 30 / min
remove_stale_passkeys 3 / min
get_user_enforcement_admin 60 / min
set_user_exemption 30 / hour
reset_enforcement_grace 30 / hour

The action-confirmation endpoints (begin_confirmation / verify_confirmation / reauth_password) require an authenticated caller, so they sit in the per-user class — the reauth_password 5 / 5 min ceiling is a second, coarse backstop on top of the per-user password-oracle lock above.

The Passkey Settings and enforcement-admin endpoints additionally require System Manager. All use POST and return their payload under message. Settings endpoints live in passkeys.passkeys.doctype.passkey_settings.passkey_settings; enforcement endpoints live in passkeys.enforcement_admin. The preview reads do not mutate credentials. Bulk stale removal refuses impersonation, serializes with settings saves, keeps flagged rows and uses normal delete hooks. See the endpoint contract. Per-user exemption and grace reset mutate enrollment authorization state and are limited to 30/hour.

Read Payload
get_resolved_rp_id() {rp_id, site_rp_id, configured_site_origin}
get_security_posture() {verdict: {headline, tone, can_bypass, bypass_labels, degraded}, rows: [{code, severity, what, why, recommendation, detectable, bypass_label}]}
get_user_enforcement_admin(user) {user, effective, enforcing, in_scope, exempt, grace_used, grace_total, grace_remaining, credential_count}

get_user_enforcement_admin is a single-user detail read, not a bulk-list API. Its 60/minute budget belongs to the calling administrator, across all target users; direct Python calls spend the same budget and raise TooManyRequestsError at the limit.

No account enumeration. First-factor login is discoverable-only — it takes no identifier, so the begin response leaks nothing about which accounts have passkeys. Ownership checks on management endpoints return a uniform “not found” so one user cannot probe another’s credentials.

API keys and OAuth tokens

OAuth password-grant token creation is guarded before insertion, including on older supported Frappe releases whose password-grant validator skips post_login. The guard shares the login veto’s account policy: users with a usable passkey and either passkey-only login or passkey second-factor requirements cannot mint a token using only a password. Site-wide password-login disablement also refuses this grant. The grant type comes from the request form consumed by core’s OAuth token endpoint; authorization-code and refresh-token grants are unchanged.

Passkeys protect interactive sign-in only. Token-authenticated API requests (an API key and secret sent as Authorization: token … or Basic …, or an OAuth Bearer token) are not subject to passkey sign-in checks, even when enrollment is enforced, a user is Passkey Only, or password login is disabled. Frappe’s own two-factor authentication behaves the same way. Treat API keys as privileged credentials: issue them sparingly, rotate them, and revoke them when they are no longer needed.

A user’s own passkey management and confirmation require a signed-in browser session. A token-authenticated request gets a BrowserSessionRequired (403) refusal from those endpoints (registration, credential list, rename and delete, the passkey-only switch, password re-auth, and the confirmation ceremony) and from every @passkey_protected action. The System Manager administration endpoints (per-user exemption and grace reset, settings reads and stale removal) accept a System Manager token like the rest of Frappe’s administration API. A token caller can therefore never open a sudo window or obtain or spend a confirmation grant, even with the user’s password. The server recognizes such a request by its session: core binds a token caller with frappe.set_user, which creates no session and sets the session id to the user name, whereas a real sign-in gets a random session id.

What the app trusts

Residual risks

Reporting a vulnerability

Follow ../SECURITY.md. Do not include vulnerability details in a public issue. Issues independently affecting Frappe core should also follow Frappe’s own current responsible- disclosure process; this app’s upstream proposal does not make the projects one security boundary.