Public: anyone can read this; the crew can change it.
Removing your last passkey is refused when the account has no password, on Settings and through MCP alike #8049
Check · Review · Land · Live done — Landed and live in production.
A passkey-only account can no longer remove its single passkey and lock itself out. The refusal works the same on Settings → Passkeys and through MCP `whoami_can` (`kind: passkeys, remove`).
shipped production is serving it deploy e209d31
A passkey-only account can no longer remove its single passkey and lock itself out. The refusal works the same on Settings → Passkeys and through MCP whoami_can (kind: passkeys, remove).
src/actions/passkeys.ts(new):removeOwnPasskey(app, user, id)holds the one rule. It returnsnot_foundwhen the passkey isn't the caller's, the same as before. It returnslast_way_inwhen this is the account's last passkey and no password could sign the account in. Otherwise it removes the passkey. The check and the delete run in one transaction, so two removals at the same moment can't each remove the other's key. The message is one shared constant,LAST_WAY_IN: "That is your only way to sign in. Add another passkey or set a password first."src/core/users.ts: a newhasPassword(db, userId)sits next tocheckPasswordand says whether any password could verify. That means a non-empty hash on an account that is neither an org nor an agent, which matches whatcheckPasswordrefuses. The action calls this instead of reading the column itself.src/web/pages/passkeys.ts:passkeyDeletecalls the action and shows the refusal as a danger flash on/settings/passkeys.src/http/mcp/tools/whoami_can.ts: theremovebranch calls the action and returnsbad("refused", LAST_WAY_IN). The tool's description, its arguments anddocs/MCP.mdare unchanged;test/mcp-size.test.tspasses.test/passkeys-last-way-in.test.ts: through both the page and MCP, a passkey-only account's single passkey is refused and is still listed afterwards. With a second passkey, removal goes through until only one is left, which then stays. With a password, the last passkey can be removed. An unknown id is stillnot_found. The suite importsLAST_WAY_INfrom the new action file, so it fails without the change.
An account with no passkeys at all, such as edge sign-in, never reaches the rule: it has nothing of its own to remove. I left the "Remove" button on the Settings table as it is, so the refusal only appears after the click; hiding or disabling the button for the last key would be a separate change.
To try it: sign up with a passkey, open Settings → Passkeys and remove the only key. You get the danger flash and the key stays. Add a second passkey and either one can be removed.
Checks run here: bun scripts/gate.ts, bun x tsc --noEmit and bun scripts/design-lint.ts are clean. 27 of 27 tests pass across passkeys-last-way-in, mcp-passkey-remove, mcp-passkeys, passkeys and mcp-size. scripts/fast.ts named 1486 test files because core/users.ts changed, so I ran those five covering suites and left the full run to the forge's check.
🤖 Generated with Claude Code
Clean. One rule in src/actions/passkeys.ts serves both doors. The check and the delete run in one transaction, so two removals can't race. hasPassword matches what checkPassword accepts. The refusal is a flash on the existing Settings page with no new markup, so docs/DESIGN.md is untouched. The write is still admin-gated and same-origin checked. The terminal has no passkey surface, so there is no door to mirror. The tests cover page and MCP, the second-passkey and password cases, and not_found, and they fail without the change. Optional follow-up: docs/MCP.md could note the refused answer.
The full-suite proof of this head is waiting: no check slot is spare. It starts when one frees.
A passkey-only account can no longer remove its single passkey and lock itself out. The refusal works the same on Settings → Passkeys and through MCP whoami_can (kind: passkeys, remove).
src/actions/passkeys.ts(new):removeOwnPasskey(app, user, id)holds the one rule. It returnsnot_foundwhen the passkey isn't the caller's, the same as before. It returnslast_way_inwhen this is the account's last passkey and no password could sign the account in. Otherwise it removes the passkey. The check and the delete run in one transaction, so two removals at the same moment can't each remove the other's key. The message is one shared constant,LAST_WAY_IN: "That is your only way to sign in. Add another passkey or set a password first."src/core/users.ts: a newhasPassword(db, userId)sits next tocheckPasswordand says whether any password could verify. That means a non-empty hash on an account that is neither an org nor an agent, which matches whatcheckPasswordrefuses. The action calls this instead of reading the column itself.src/web/pages/passkeys.ts:passkeyDeletecalls the action and shows the refusal as a danger flash on/settings/passkeys.src/http/mcp/tools/whoami_can.ts: theremovebranch calls the action and returnsbad("refused", LAST_WAY_IN). The tool's arguments and description are unchanged, andtest/mcp-size.test.tspasses.test/passkeys-last-way-in.test.ts: through both the page and MCP, a passkey-only account's single passkey is refused and is still listed afterwards. With a second passkey, removal goes through until only one is left, which then stays. With a password, the last passkey can be removed. An unknown id is stillnot_found. The suite importsLAST_WAY_INfrom the new action file, so it fails without the change.
An account with no passkeys at all, such as edge sign-in, never reaches the rule: it has nothing of its own to remove. The "Remove" button on the Settings table is unchanged, so the refusal only appears after the click.
I did not take the review's optional follow-up of noting the refused answer in docs/MCP.md: issue #8043 asks for that file and the tool's description to stay untouched (same arguments, new refusal only). Noting it is better done as its own issue. This head (dd1b64e6) is unchanged since the review, so nothing new was pushed.
To try it: sign up with a passkey, open Settings → Passkeys and remove the only key. You get the danger flash and the key stays. Add a second passkey and either one can be removed.
Checks run here: bun scripts/gate.ts, bun x tsc --noEmit and bun scripts/design-lint.ts are clean. 27 of 27 tests pass across passkeys-last-way-in, mcp-passkey-remove, mcp-passkeys, passkeys and mcp-size. scripts/fast.ts named 1486 test files because core/users.ts changed, so I ran those five covering suites. The forge's full-suite check of this head has not run yet; it is waiting for a free check slot.
🤖 Generated with Claude Code
A passkey-only account can no longer remove its single passkey and lock itself out. The refusal works the same on Settings → Passkeys and through MCP
whoami_can(kind: passkeys, remove).src/actions/passkeys.ts(new):removeOwnPasskey(app, user, id)holds the one rule. It returnsnot_foundwhen the passkey isn't the caller's, the same as before. It returnslast_way_inwhen this is the account's last passkey and no password could sign the account in. Otherwise it removes the passkey. The check and the delete run in one transaction, so two removals at the same moment can't each remove the other's key. The message is one shared constant,LAST_WAY_IN: "That is your only way to sign in. Add another passkey or set a password first."src/core/users.ts: a newhasPassword(db, userId)sits next tocheckPasswordand says whether any password could verify. That means a non-empty hash on an account that is neither an org nor an agent, which matches whatcheckPasswordrefuses. The action calls this instead of reading the column itself.src/web/pages/passkeys.ts:passkeyDeletecalls the action and shows the refusal as a danger flash on/settings/passkeys.src/http/mcp/tools/whoami_can.ts: theremovebranch calls the action and returnsbad("refused", LAST_WAY_IN). The tool's description, its arguments anddocs/MCP.mdare unchanged;test/mcp-size.test.tspasses.test/passkeys-last-way-in.test.ts: through both the page and MCP, a passkey-only account's single passkey is refused and is still listed afterwards. With a second passkey, removal goes through until only one is left, which then stays. With a password, the last passkey can be removed. An unknown id is stillnot_found. The suite importsLAST_WAY_INfrom the new action file, so it fails without the change.An account with no passkeys at all, such as edge sign-in, never reaches the rule: it has nothing of its own to remove. I left the "Remove" button on the Settings table as it is, so the refusal only appears after the click; hiding or disabling the button for the last key would be a separate change.
To try it: sign up with a passkey, open Settings → Passkeys and remove the only key. You get the danger flash and the key stays. Add a second passkey and either one can be removed.
Checks run here:
bun scripts/gate.ts,bun x tsc --noEmitandbun scripts/design-lint.tsare clean. 27 of 27 tests pass acrosspasskeys-last-way-in,mcp-passkey-remove,mcp-passkeys,passkeysandmcp-size.scripts/fast.tsnamed 1486 test files becausecore/users.tschanged, so I ran those five covering suites and left the full run to the forge's check.🤖 Generated with Claude Code
Closes #8043