Install, upgrade, uninstall

This is the self-hoster guide to getting the passkeys app onto a Frappe bench, keeping it current, and removing it safely. Configuration of the login modes themselves is in configuration.md.

Supported versions

Branch Supported Why
v15 v15.108.0 and newer Use version-15.
v16 v16.18.3 and newer Use version-16.
develop Integration target Pre-release, for unreleased Frappe; not for production.

The app pins webauthn==2.8.0, the version CI tests on every Frappe line; changing that authentication-critical dependency requires the full resolver and ceremony matrix. Python >=3.10,<3.15 is required.

Each branch’s pyproject.toml declares its Frappe range (version-15: >=15.108.0,<16.0.0; version-16: >=16.18.3,<17.0.0). Bench only warns about that range, so installing refuses a Frappe below the floor of its line. CI runs against a reviewed, pinned Frappe commit for each line. A daily workflow runs the develop branch, where every change lands first, against the moving Frappe version-15 and version-16 tips and fails when an upstream change breaks a suite.

Install

# Pick the branch that matches your Frappe version: version-15, version-16, or develop
bench get-app --branch version-16 https://github.com/Andrometiq/frappe-passkeys
bench --site <site> install-app passkeys

bench get-app fetches the app and installs its Python requirements (the webauthn wheel). If the wheel is missing later — for example after a manual checkout — run bench setup requirements for the app before enabling any mode; the Passkey Settings validator checks package presence on enabled settings saves. When either login mode changes from off to on, it also verifies that the ceremony engine imports successfully in a bounded child process. The probe runs a fixed import with the bench’s own Python interpreter, no request-derived arguments and no shell. A failure refuses the save; crypto is never loaded into the serving worker by this check. Ordinary saves of already-enabled modes and mode disabling do not launch the probe. This is an enablement-time check, not ongoing dependency or worker-health monitoring.

Installing is not enabling. install-app creates the DocTypes and the Passkey Settings single with every login mode off. Nothing about authentication changes until you enable a mode in Passkey Settings — see configuration.md.

The before_install guard

before_install (passkeys/install.py) refuses the install in one case. It runs before install so a refusal leaves zero site state.

Site configuration, reverse proxy, RP-ID and origin

A passkey is bound to a Relying Party ID (RP ID) — a bare host name — and to a set of exact origins. These are resolved once, at enable time, from pinned configuration; the app never derives them from the live Host or X-Forwarded-* request headers. Two consequences for deployment:

If a request arrives on a host that is not among the configured origins, the begin ceremony fails closed, writes a structured log line (passkeys: request host … not in configured origins), and the Passkey Settings page shows a red mismatch banner — fail-closed is always diagnosable. See operations.md for the host-change playbook.

Upgrade

bench --site <site> backup --with-files
cd apps/passkeys && git pull --ff-only && cd ../..
bench build --app passkeys
bench --site <site> migrate
bench restart

Building before migrating is deliberate: migrate’s cache flush then also picks up the freshly built asset map. (bench build’s own Redis invalidation can fail silently — if the UI looks stale after an upgrade, run bench --site <site> clear-cache && bench --site <site> clear-website-cache.)

bench migrate runs the app’s after_migrate hook, which syncs the User-form passkey section. An upgrade changes no settings; enabled modes stay enabled.

Disable vs uninstall

Disable (turn the modes off in Passkey Settings) is the reversible pause: all passkey UI disappears, the login and second-factor ceremonies refuse, but every credential row is preserved and the action-confirmation primitive keeps working. Re-enabling restores the modes. This suspends the site’s passkey login controls; it is not an account-recovery procedure. Use Desk recovery for lockouts, and reserve disabling controls for its warned last resort.

Uninstall drops the app’s DocTypes and their tables. On its own that would destroy every stored credential and orphan the passkeys held on users’ authenticators (the server-side public key they matched would be gone). To make removal recoverable, before_uninstall exports every credential first (see “Credential export on uninstall” below), so an uninstall is a reversible move rather than a one-way delete. Still, only uninstall when you mean it.

Uninstall

bench --site <site> uninstall-app passkeys

before_uninstall blocks the removal — with a remediation message — in two lockout cases, so you cannot strand your users by accident:

Once the guards pass, uninstall also deletes the app’s per-user nudge state and the User-form passkey section, so a later reinstall is a clean slate. Cached challenge / grant / sudo state in Redis expires on its own.

Credential export on uninstall

Because dropping the tables would otherwise destroy every enrolled passkey, before_uninstall first writes all WebAuthn Credential and WebAuthn User Handle rows to a single JSON file, then prints its path in the uninstall output:

passkeys: credentials exported before uninstall -> .../private/files/passkeys-credentials-YYYYMMDD-HHMMSS.json
passkeys: to restore them after reinstalling, run in `bench --site <site> console`:
passkeys:   from passkeys.install import import_credentials; import_credentials("<path>")

Restore (reinstall on the same site). The export carries no Passkey Settings, and a passkey only works under the RP ID it was created for. Before uninstalling, record the site’s Passkey RP ID, Passkey Origins and Trusted App Origins. Reinstall the app, restore those values through a validated settings save, then replay the file:

bench --site <site> install-app passkeys
bench --site <site> console
>>> import frappe
>>> settings = frappe.get_single("Passkey Settings")
>>> settings.passkey_rp_id = "<the recorded RP ID; blank if it was blank>"
>>> settings.passkey_origins = "<the recorded origins, one per line>"
>>> settings.passkey_app_origins = "<the recorded trusted app origins, if any>"
>>> # Enable a passkey login mode before importing passkey-only users — otherwise the
>>> # restore refuses (those users would be locked out):
>>> settings.login_with_passkey = 1
>>> settings.save()
>>> frappe.db.commit()
>>> from passkeys.install import import_credentials
>>> import_credentials("sites/<site>/private/files/passkeys-credentials-<timestamp>.json")
{'credentials_created': 4, 'credentials_skipped': 0, 'handles_created': 3, 'handles_skipped': 0}

The enable-a-mode step is only required when the export carries Passkey Only Login users; for a plain restore it is harmless (re-enabling the mode you were already running). The RP ID and origins are not optional: rows restored under a different RP ID are stale and cannot authenticate there. A passkey-only handle cannot be restored unless it has a usable credential under the destination RP; the handle floor fails closed. Credential insertion precedes handle validation; if that validation raises, roll back the failed import transaction before repairing configuration and retrying. Restoring the original RP/origins makes retained enabled rows usable again. Blank stored RP IDs remain stale; there is no backfill or RP rewrite. By default, import_credentials requires both passkey tables to be empty. This makes an accidental import into a live credential set fail before merging anything. allow_existing=True is an explicit, operator-reviewed merge mode, not an idempotency convenience: inspect handle ownership and credential conflicts before using it. Matching rows are skipped, inconsistent or disabled-user rows are rejected and reported, and valid rows may still be imported. Signature counters are restored verbatim (never reset to zero), and credentials are restored before handles. On a clean, same-site restore under the original RP ID, authenticators users still hold continue to match the restored public keys.

Possible core-handoff input. The export can be a migration input if Frappe core implements an importer for it; the app yields to a native implementation only once that implementation advertises the exact handover marker.