Operations — day-two playbooks

Running a site with passkeys enabled. For first install see install.md; for getting a locked-out user or admin back in see recovery.md.

What is durable vs ephemeral

This matters for every backup, restore, and deploy.

Durable — in the site database, travels with a backup:

Ephemeral — in Redis, TTL-expiring, deliberately not durable:

A Redis flush (or a deploy that clears the cache) cancels only in-flight ceremonies: the worst case is a user retrying one sign-in. Nothing enrolled is lost. Sudo windows and grants simply have to be re-earned. The OTP-fallback prefix alone is registered in persistent_cache_keys: core’s stock OTP form omits usr, causing a site-cache clear before the login veto consumes the marker. This hook preserves the marker across that clear, not across a Redis flush; its TTL and atomic, single-use consumption still apply.

Before you enable passkeys on a production site

Release CI tests the app against pinned Frappe baselines; it does not test your deployment. Before enabling passkeys on a site people depend on:

  1. Take a database and private-files backup and confirm it restores. Keep a separate copy of the site’s encryption_key: enabling a mode and verifying a credential export both need it.
  2. Try the release on a staging copy behind the same TLS, reverse proxy, and host name as production, with the RP ID and Passkey Origins you will use.
  3. Make sure an administrator can run the console recovery in recovery.md without relying on the login path you are changing.
  4. Enable it for a small group first, with password login still on, and watch the risk events, enrollment failures, and support requests.
  5. Turn on Enforce, passkey-only accounts, or OTP-fallback-off only after that period and a recovery drill have gone well.

To back out a rollout, turn both modes off in Passkey Settings — the reversible pause described in Disable vs uninstall. That is a site decision, not a way to unlock one user; lockouts go through recovery.md.

Changing the RP ID or moving domains

A passkey is usable only when enabled and its stored RP ID exactly matches the site’s operational RP ID (case-normalized). A different or blank stored RP ID is stale. Changing from login.example.com to example.com also makes the old passkeys stale: widening the scope does not migrate keys. Rows and private keys are kept; reverting to the original RP and working origins makes retained, enabled passkeys work again. Disabled current-RP rows are not stale.

  1. Before a move, keep a tested recovery path for a System Manager. Review Passkey Settings → Relying Party. The RP change confirmation previews enabled passkeys and distinct users that will stop or start working; invalid origin entries must be fixed before proceeding.
  2. Set host_name, the RP ID and exact trusted origins for the destination. Blank RP uses the exact host_name host, so changing that host can change the RP without a settings dialog. Moving between trusted origins under an unchanged RP does not require re-enrollment.
  3. Check the resolved configuration, the stale count and users’ cards. Stale-only users can use their password (plus core OTP where required); their passkey-only flag persists and applies again as soon as one usable passkey exists. The OAuth password grant follows the same rule. Core Disable Username/Password Login remains enforced. Users without another method need admin-assisted recovery.
  4. Have users enroll for the new RP. Stale rows still consume the per-user cap. Users can remove their own with a management re-authentication; a System Manager can filter WebAuthn Credential → RP ID, or use Remove stale passkeys in settings. The bulk action includes disabled stale rows, keeps flagged rows for review, and uses the usual removal notifications when enabled. Save RP ID or origin edits before using the action; its confirmation names the saved RP ID. Deletion cannot be undone. It refuses while no operational RP exists or when the RP changed since the page loaded; fix the configuration or reload first.

The System Manager’s dismissible Desk banner distinguishes off (no operational RP/path, passkey requirements paused), app only (mobile path survives, requirements still apply), and web works at other configured origins (lists those origins). Missing RP or valid origins also shows an off warning. The banner follows the current Desk page (home, list or form), below the navbar, until dismissed for that diagnostic in the browser session. The fix link can suggest the configured site’s host in settings; it never saves automatically. Users see stale notes, an enrollment prompt when only stale keys remain, or a site-off notice with Add hidden. Enforcement grace is not reset by a move; an administrator can reset it for a user who needs time to enroll.

For a planned family of subdomains, choose an apex RP such as example.com before enrollment, only if you control and trust its entire subdomain scope. Keep the exact origin allowlist narrow; an apex RP does not automatically trust every origin. This lets a later move from login.example.com to portal.example.com retain the RP and keys.

host_name is reloaded per request, not at a worker-restart boundary. In-flight requests can still use the old configuration; database changes also have a request-length visibility window. For a strict cutover, stop incoming traffic, drain requests, change configuration, then resume. See residual risks.

For a staging clone, repair the intended RP/origins and recover one manager first; do not routinely clear everyone’s passkey-only flags. After any restore, review the inventory: a credential revoked after the backup was taken returns with the backup and may work again.

Credential and counter incident response

Monitoring the risk events

Security-relevant events are recorded as Activity Log rows and structured log lines.

Activity Log rows (filter on the content field, which is passkeys:<event>):

Event Meaning
passkeys:passkey_added / passkey_removed / passkey_disabled A credential was added, removed, or admin-disabled.
passkeys:passkey_flagged A sign-count regression / anomaly was recorded on a sign-in.
passkeys:fallback_used A passkey holder completed the second factor with a one-time code instead.
passkeys:external_login_enrollment The restricted first-enrollment-on-external-login path was used.
passkeys:password_login_by_passkey_holder A user who holds a usable passkey signed in with their password instead. Recorded only when passkey_notify_password_login is on (default off).
passkeys:enforce_incapable_device A user in scope for enrollment enforcement reported their device cannot create a passkey (the block-and-notify-admin path); always recorded, even when the admin email is deduped.
passkeys:grant_issued / passkeys:grant_consumed A confirmation grant was issued or accepted for an action. A mismatched presentation spends the token without a grant_consumed event.

Structured errors are in Error Log; filter its method (title) field for passkeys:. Grant audit lines are also written at INFO to the passkeys logger, outside the Activity Log transaction. Frappe’s default log level drops INFO, so treat the Activity Log as the record unless you have raised that logger’s level. Entries worth alerting on:

For enrolled second-factor users, alternate/core login completions are expected to fail unless this app completed the passkey or its fallback_to_otp flow issued the one-time marker consumed after core OTP succeeds. A direct core OTP completion without that marker is not a supported bypass. A password rotation between the password and passkey legs also fails by design because the ceremony’s keyed, non-reversible password-hash version no longer matches.

The self-hoster password-disable override (advanced, at your own risk)

Goal: a site that wants passwords fully disabled site-wide with passkeys as the only first factor, on v15 / v16, before Frappe core natively supports it.

Why it isn’t already possible on released branches. Two separate things:

  1. Core refuses to turn on disable_user_pass_login unless at least one of Social Login, LDAP, or Login With Email Link is enabled (validate_user_pass_login). Passkeys are not yet in that allowlist on v15 / v16.
  2. Even if it were on, disable_user_pass_login is enforced at login by throwing for any username/password login — including Administrator (core’s login path has no Administrator exemption for this flag, unlike the per-user passkey-only veto).

Because of (1), the released-branch levers for “no passwords” are the per-user passkey_only_login flag (documented in configuration.md) or simply leaving email-link on — not the site-wide switch.

The override, for operators who accept the risk, is a local patch to core’s validate_user_pass_login so it counts an enabled passkey login mode as a surviving method, letting you turn disable_user_pass_login on. Passkey login does not go through core’s login(), so it keeps working with the flag on; password first-factor login is then refused site-wide. Email-link and social login do not go through that check, so they stay open for any user who is neither Passkey Only nor enrolled with passkey as a second factor; turn them off separately (System Settings for email-link, the Social Login Key records for social login) if passwords are meant to be the only thing removed.

When it is safe:

When it is a foot-gun:

Recovery: use the recovery guide. Recover one account first. Reopening password sign-in site-wide is a last resort, at your own risk: for that window the whole site is not protected by the password-disable control. Verify the requester’s identity out of band and rule out a planned attack, including social engineering an admin into lowering defences. The guide keeps the console change and its restoration steps together.

The upstream plan replaces the local validator override by adding passkeys to core’s validate_user_pass_login allowlist, so a local patch would no longer be needed.

Console authority (stated once)

Raw database writes from an operator console bypass DocType validation and document hooks, including credential-count and settings guards. Document saves still run their controllers. Use that authority to restore one trusted manager when nobody can reach Desk; see console access. Site-wide protection changes are reserved for the guide’s last resort.

Administrator break-glass boundary: Administrator is exempt from the per-user passkey-only login veto only. If Administrator has a usable credential while Passkey as Second Factor is active, alternate/core login paths require the app’s passkey or one-time OTP fallback like any other enrolled user. The site-wide disable_user_pass_login flag also has no Administrator exemption. Keep tested console access outside the login path; do not treat an Administrator password as an unconditional recovery guarantee.