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:
WebAuthn Credentialrows — the credential public keys, sign counters, backup flags, labels, and metadata. These are the authoritative record. No server secret is stored; a public key is useless to an attacker who steals a backup.WebAuthn User Handlerows — the opaque 64-byte user handles and the per-user Passkey Only Login flag. Deleting a handle requires removing all of that user’s credentials first, including disabled ones: a credential without its handle can no longer sign in without a password.Passkey Settings— every knob.- Per-user enrollment-nudge state, enforcement grace counters and incapable-device
notification markers (default-value rows under the
__passkeysparent). User renames carry these values forward; merges retain each existing target value and otherwise carry the source value. The old keys are removed. Merging two users who both have a WebAuthn User Handle is refused: delete the source user’s passkeys and handle first.
Ephemeral — in Redis, TTL-expiring, deliberately not durable:
- In-flight login / registration / confirmation ceremonies and their challenges.
- The uv-setup step-up state.
- One-time OTP-fallback authorization markers (five-minute TTL).
- Action-confirmation grants and the “sudo” re-auth windows.
- The guest browser-binder cookie value hashes and the per-user password-failure throttle counters.
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:
- 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. - 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.
- Make sure an administrator can run the console recovery in
recovery.mdwithout relying on the login path you are changing. - Enable it for a small group first, with password login still on, and watch the risk events, enrollment failures, and support requests.
- 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.
- 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.
- Set
host_name, the RP ID and exact trusted origins for the destination. Blank RP uses the exacthost_namehost, 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. - 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.
- 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
-
Sign-count regression (possible clone). When an assertion presents a signature counter lower than the stored value, the credential is flagged (
flagged=1, reasonsign_count_regression) and its owner is emailed on the first flag only. The sign-in still succeeds unless Hard-fail on Sign Count Regression is on. A counter that is equal and non-zero (an exact replay) is always rejected. To respond: have the owner review recent sign-ins and delete the credential if they don’t recognize it. -
Impossible backup state. If an authenticator’s backup-eligibility flag ever changes from what was recorded at registration, or presents backed-up without being backup-eligible, the ceremony is refused outright. This needs no operator action — it fails closed.
-
Revocation. Users revoke their own passkeys from the management surface (the Desk User form or the
/passkeysportal page). A delete requires a live sudo window (a fresh confirmation or password re-auth) and refuses to remove a user’s last passkey when that would lock them out. System Managers can revoke any user’s credential from the WebAuthn Credential DocType — prefer disabling (setenabled=0) over deleting so the row survives for forensics. With Notify on Passkey Changes on, the owner is emailed. The app refuses to remove or disable the last usable credential while Passkey Only Login or site-wide password disable is set. Follow account recovery for the correct order; the site-wide case requires its own recovery path.
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:
passkeys: request host … not in configured origins— a request reached the site on a host outside the configured origins (proxy misconfig, domain move, or a clone). Diagnose with the host-change playbook above.- Grant issued / consumed lines (logger
passkeys) — the audit trail for the action-confirmation primitive.
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:
- Core refuses to turn on
disable_user_pass_loginunless 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. - Even if it were on,
disable_user_pass_loginis 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:
- Every user who must retain access — including Administrator — has at least one enrolled, working passkey, verified before you flip the switch.
- You keep tested operator access outside the login path, and have rehearsed recovery of one manager.
When it is a foot-gun:
- Flipping
disable_user_pass_loginon while Administrator has no working passkey locks the site’s owner out of the password door too — this flag, unlike the per-user passkey-only rule, does not exempt Administrator. - It is a local core patch: it is reverted by any Frappe upgrade, and it is outside the app’s mergeability guarantees. Re-apply and re-test after every update.
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.