Skip to content

Go Passwordless Without Locking Anyone Out

Passkeys remove the password, and with it the “forgot password” link everyone falls back on. This guide covers what replaces that link in Seamless Auth, so a lost phone or a policy change does not leave a user, or every administrator, unable to sign in. It ties together the recovery and safety features that shipped in auth API 0.20.0, then gives a recommended setup for a small team and for a larger organization.

Every setting, route and screen here was checked against seamless-auth-api v0.20.0, admin dashboard 0.11.0, seamless-cli 0.26.0 and @seamless-auth/react 0.19.0. The versions that go together are in the Compatibility Matrix.


Plan for two kinds of failure. A user loses the device that holds their only passkey. An administrator changes sign-in policy in a way that refuses the factors people hold, possibly including their own. Each row below covers one of those, and the rest of the page explains each.

Way back in Covers Default Who acts
A second passkey A lost or broken device Allowed The user
Recovery codes Every passkey lost On The user
Self-service account recovery Every factor lost, no codes left Off The user, after a delay
Admin device replacement Anything, with identity proofing Always An administrator
Break-glass admins A policy change that locks admins out Not set A designated admin
Staged policy and dry run Finding the lockout before it happens Always An administrator

None of these needs a password. Recovery codes and the break-glass exemption are not login methods, so no login_methods change can take them away.


The cheapest protection is a second passkey. A signed-in user can enroll as many as they like with POST /webauthn/register/start and /webauthn/register/finish (access session), and the API will not delete the last one: DELETE /users/credentials answers 400 with You must keep at least one credential. when only one is left.

  • Synced passkeys already survive a lost device. A passkey in iCloud Keychain, Google Password Manager or another password manager comes back on the user’s next device. The default authenticator_policy allows them (syncedPasskeys: "allow").
  • Device-bound keys do not. If you set syncedPasskeys: "block", or pin an AAGUID allow list, every passkey is tied to one authenticator, and a second one (a hardware security key, for example) is the difference between a lost device and a recovery. See Authenticator Policy.
  • The React SDK asks for one. The PasskeyManagement component lists, renames and deletes the user’s passkeys, and tells a user with one passkey to add a second on another device or a security key. See React SDK.
  • Get people onto a first passkey. For users who still sign in with a code or magic link, prompt_passkey_enrollment asks them to enroll one after sign-in, and the dashboard’s Enrollment area invites the rest. See Get Users Onto Passkeys.

The coverage report shows how many active users hold a passkey, and how many credentials are backup eligible (synced). It does not show how many passkeys each user holds, so there is no report of users with only one.


Recovery codes are the user’s own way back in when every passkey is gone. They are on by default.

  • POST /recovery-codes (access session) returns ten single-use codes in the form XXXXX-XXXXX. This is the only time they are shown. Generating a new set invalidates the old one, and replacing an existing set needs a fresh step-up.
  • GET /recovery-codes reports how many are left (remaining, total, createdAt).
  • POST /recovery-codes/redeem, with the pre-auth token from /login, signs in with one code. It is rate limited like an OTP and is not subject to account lockout. It works whatever login_methods or phishing_resistant_only say.
  • Using a code notifies the account’s verified email and phone, so a code used by someone else does not go unnoticed.

A code does not give a full session. It issues a recovery session (recovery: true in the response, the access token and GET /users/me). Until the user enrolls a new passkey or an authenticator app, every access route except enrollment, recovery codes, GET /users/me and logout answers 403 enrollment_required. The server adapters’ requireAuth refuses a recovery session too, unless a route passes allowRecoverySession: true. Pass it only on the routes your app uses to drive that enrollment.

In the React SDK’s bundled screens, a user who enrolls a passkey or authenticator app and has no codes is shown a new set straight away (they can skip it), the sign-in screen offers “Use a recovery code”, and RecoveryCodeSettings shows how many are left and generates a new set. Custom screens use the recovery methods on @seamless-auth/client 0.8.0 or @seamless-auth/react-native 0.4.0. GET /system-config/public reports recoveryCodes, so a sign-in screen only offers them where they work.

To turn them off, set recovery_codes to {"enabled": false, "requireAtEnrollment": false} (env seed RECOVERY_CODES, see System Config).


For a user with no passkey and no codes left, self-service recovery proves control of a verified email address or phone number, then makes them wait. It is off by default, because it is a way into an account that does not need any of the account’s factors.

Turn it on with account_recovery (env seed ACCOUNT_RECOVERY):

Terminal window
seamless config set account_recovery '{"enabled":true,"delay":"24h"}'

delay is between 1h and 30d (default 24h). Send the whole object; a field left out takes its default.

How it protects the owner:

  • POST /recovery/start sends a code to the verified address for the chosen channel, and answers the same whether or not the account has one.
  • POST /recovery/verify opens the request and starts the delay. Every verified address on the account is told, with a cancel link (${FRONTEND_URL}/recovery/cancel#<token>). If no notice reaches any verified address, the request is voided and the API answers 503 recovery_notice_failed. Five wrong codes void the code.
  • POST /recovery/complete, once the delay has passed, signs out every session on the account and issues a recovery session, so the user’s next step is enrolling a new passkey or authenticator app.
  • A signed-in owner can list open requests with GET /recovery/requests and cancel one with DELETE /recovery/requests/:id. Cancelling keeps working even after the feature is turned off.

Things to get right before turning it on:

  • Notices must deliver. The delay only protects an owner who hears about the request. Configure email and SMS delivery. If your server adapter owns delivery, it must list account_notice_email and account_notice_sms in x-seamless-auth-delivery-kinds, or every verify answers 503.
  • The cancel link must resolve. With the React SDK, mount AuthRoutes at the root of FRONTEND_URL so /recovery/cancel exists. The bundled screens include /account-recovery and /recovery/cancel.
  • It is unavailable in phishing_resistant_only mode, since it proves only an address. GET /system-config/public then reports accountRecovery: false. Recovery codes are unaffected.

The full flow, including how a user resumes a request after the delay, is in the auth API’s account recovery guide.


When a user has nothing left, an administrator can reset their factors after proving who they are. On the dashboard’s user detail page, or with seamless users prepare-device-replacement, the admin records identity proofing (in_person or remote_exception, an evidence reference, and an approver for a remote exception). By default it revokes the user’s sessions, removes their passkeys and disables their authenticator app. It needs admin:write and a fresh step-up. See Device replacement recovery.

It does not touch recovery codes, and it does not sign the user in. Afterwards the user needs a way to start a session and enroll a new passkey: a login method the policy allows (an email code or a magic link, for example), or an unused recovery code. Under phishing_resistant_only, a user with no passkey can start a session only with a recovery code, so check what the user will sign in with before you remove their passkeys.


A bad policy change can lock every administrator out: phishing_resistant_only with no enrolled keys, a method list that leaves out what admins use, or an authenticator allow list nobody’s key is on. BREAK_GLASS_EMAILS names admin accounts that sign-in policy cannot lock out. It is an environment variable, not a system_config key, because anything the API can edit is something a bad edit can remove.

Terminal window
BREAK_GLASS_EMAILS=breakglass-1@example.com,breakglass-2@example.com
  • Being listed is not enough. The account must also hold a role that satisfies admin:write. Removing the role, revoking the account, or removing the address (and restarting) ends the exemption. At boot the API logs how many listed entries are not admin accounts.
  • What it is exempt from, for its own sign-in: login_methods, phishing_resistant_only, passkey_login_fallback_enabled and recovery_codes.enabled, and authenticator_policy when it enrolls a passkey.
  • What still applies. It must sign in with a passkey, an authenticator app or a recovery code. Email and SMS codes, magic links and OAuth are refused for it (403 login_method_disabled) even where everyone else may use them, because each proves only an address or an outside identity. Lockout still applies to its authenticator app codes.
  • Every use is announced. A sign-in or enrollment that relied on the exemption records a break_glass_sign_in or break_glass_enrollment auth event with severity: "high" in its metadata, and emails every admin. A sign-in the policy would have allowed anyway is not announced.

Set up each break-glass account while the policy still allows it: enroll a passkey (ideally a hardware key kept offline) and generate its recovery codes, and keep both where at least two people can reach them. Neither the dashboard nor the CLI has a recovery code screen, so generate the codes from your application’s account screens (for example the React SDK’s RecoveryCodeSettings) while signed in as that account. Use the account only to recover the deployment: sign in, correct the policy, sign out. After any use, regenerate its codes. The auth API’s break-glass section covers rotating it to a different account.

See also Service And Security Secrets.


The policy keys are the system_config keys that decide who can sign in, and how: login_methods, passkey_login_fallback_enabled, phishing_resistant_only, prompt_passkey_enrollment, authenticator_policy, lockout_policy, recovery_codes and account_recovery. Every change to them is kept as a numbered version, and a change can be staged as a draft and dry-run before it applies.

On the admin dashboard, the Policy page (titled Sign-in Policy) holds all of it:

  1. Start Draft, edit, and Save Draft. There is one draft per instance, and nothing changes for users until it is published.
  2. Run Dry Run with 1 to 30 days of sign-ins. It evaluates the draft, or your unsaved edits, against completed sign-ins in that window, and lists every account the draft would refuse, with the reason and whether the account would have No remaining method. Those accounts are listed first.
  3. Publish. Publishing needs admin:write and a fresh step-up, and is refused with 409 policy_version_conflict if someone else published since your draft was started.
  4. Roll back from Version History if it goes wrong. A rollback publishes a copy of the older version as a new version, so history is never rewritten.

The same operations are API routes under /system-config/policy (GET /system-config/policy, PUT and DELETE /system-config/policy/draft, POST /system-config/policy/publish, /rollback and /dry-run, and GET /system-config/policy/versions). See the auth API’s policy changes guide for request bodies and the full list of dry-run reasons.

Read the dry run with its limits in mind:

  • It only sees accounts that signed in during the window. An account that has not signed in for 30 days is not evaluated, however the draft would treat it.
  • “No remaining method” ignores recovery codes. It is worked out from the login methods and OAuth identities the account would have left, so a listed account may still hold codes. Treat it as the worst case.
  • Authenticator rules apply at enrollment, not sign-in. A passkey the draft’s authenticator_policy would not admit keeps signing in. The dry run reports it as authenticator_not_allowed or synced_passkey_blocked, meaning the user could not enroll that authenticator again, for example on a replacement device.
  • It evaluates at most 10,000 accounts and says so (capped) when it reaches the limit.
  • Break-glass accounts are counted, never listed, since policy does not apply to them.

Staging is optional. A whole-config save on the dashboard’s System page, PATCH /system-config/admin, or seamless config set applies at once, and is recorded as a config_patch version that can be rolled back like any other. The CLI has no policy draft, dry-run or rollback commands; seamless config apply --dry-run only previews a config file and does not evaluate sign-ins.


For a single application with a handful of administrators and users who mostly know each other.

  1. Keep recovery codes on (the default) and make sure users save them. The React bundled screens offer a set at first enrollment; if you build your own screens, offer one there too.
  2. Ask for a second passkey, or rely on synced passkeys and keep syncedPasskeys: "allow". Mount PasskeyManagement (or an equivalent) on the account settings page.
  3. Leave self-service recovery off unless you have reliable email delivery and no administrator available to do a device replacement. When an administrator can check who someone is, device replacement is the stronger path.
  4. List one break-glass account in BREAK_GLASS_EMAILS, separate from your daily admin account, with a hardware key and recovery codes kept offline. Make sure the daily admin account has an authenticator app, since admin writes need a step-up and the CLI steps up with TOTP.
  5. Dry run before every policy change, with 30 days of sign-ins, and publish only when no account shows “No remaining method”. Keep the previous version number handy so you can roll back.
  6. Leave phishing_resistant_only off until the coverage report shows everyone holds a passkey. When you turn it on, recovery codes and device replacement are the only ways back for a user who loses every passkey.
Section titled “Recommended Setup For A Larger Organization”

For many users, several administrators, or a compliance requirement to reach passkey-only sign-in.

  1. Enroll before you enforce. Turn on prompt_passkey_enrollment, invite users from the dashboard’s Enrollment area, and track the coverage report until passkey coverage is where you need it. See Get Users Onto Passkeys.
  2. Require backups by policy where you block synced passkeys. With syncedPasskeys: "block" or an AAGUID allow list, every passkey is device-bound, so make two keys per person part of onboarding. Recovery codes stay on.
  3. Tighten in stages, each one drafted and dry-run. For example: first turn off passkey_login_fallback_enabled, so users who hold a passkey must use it; then remove the address-based login_methods; then turn on phishing_resistant_only. Before each publish, run the dry run over 30 days, contact every account listed with “No remaining method”, and watch sign-in failures on the dashboard’s Events page afterwards. Roll back if refusals spike.
  4. Decide on self-service recovery deliberately. It is unavailable once phishing_resistant_only is on. Before that, if you enable it, use a longer delay than the default so notices have time to reach the owner, and confirm your adapter forwards account notices. Many organizations will prefer device replacement with identity proofing, recorded with an evidence reference such as a ticket number.
  5. Run two or more break-glass accounts, each with its own hardware key and recovery codes, stored in separate places that need two people to open. Alert on break_glass_sign_in and break_glass_enrollment events, treat any unplanned one as an incident, and regenerate codes after every use.
  6. Watch the recovery events. recovery_code_used, account_recovery_verified, account_recovery_completed, admin_device_replacement_recovery and policy_published are the events to review. See Auth Events.