Files
redefined-designs/docs/ops/account-recovery.md
T
synAdminandClaude Opus 5 90e372d6bd
Linting / lint (pull_request) Successful in 3m37s
SonarQube Analysis / sonarqube (pull_request) Failing after 35m39s
feat(admin): move a customer's account to an address they can reach (#337)
The third step of the only recovery route a customer who has lost their mailbox has. The first two are contacting the shop and being verified against order history. The third had no implementation, so the answer was a hand-written database edit that left no record of who did it or why.

The thing to say plainly, because everything here follows from it: this operation and an account takeover are the same operation. They differ only in whether the verification was sound, and nothing in the software can check that. What the software can do is make the change recorded, announced, and complete in its effects.

Recorded. The endpoint refuses without a written reason, and the reason is stored against the account. That row is the only thing that tells a genuine recovery from a takeover afterwards, which is why a hand edit was never acceptable and why the field is required by the server rather than merely collected by the form. It is never shown to the customer: it is a note about how somebody was verified and can name things the customer should not be handed back.

There is no column for who did it. Admin access is one shared gate secret in front of a single operator, so such a column could only ever hold a constant, and a constant dressed up as an identity is worse than an honest absence.

Announced, to the address being replaced. If the recovery was sound that reaches nobody and costs nothing. If it was not, it reaches the real owner, who is the only person in the world who can say so, and that is the only reason this endpoint is safe to have at all. Its own template rather than the self-service one, because that copy says to contact us if you did not make this change, and here somebody already did — the sentence would be addressed to the customer who just did the thing it asks for, while the person who needs to act on it did nothing.

The new address is marked unverified and sent a confirmation link. Somebody reading an address out over the phone has not demonstrated they can receive mail at it, and that is the commonest way this goes wrong harmlessly.

Complete in its effects. The move signs the customer out everywhere, removes every passkey, and cancels reset links already sent. That is the conclusion #42 reached for password reset, and it applies here with more force: somebody the system cannot identify asked for this change, so a session or a credential surviving it is one the new owner cannot see and cannot revoke, and a reset link sitting in the mailbox being taken away would let whoever still reads it take the account straight back.

The password is left alone. What the customer lost was the mailbox, so demanding a new one adds a step for no gain.

The verification-email helper moved out of the customers route into its own module, for the reason session creation moved out for passkeys: two implementations that agree today are two that can be changed one at a time, and the one that gets forgotten is whichever the manual testing does not exercise. This path runs perhaps once a year, so it is exactly the one that would rot.

The admin drawer gains the action next to the address rather than among the account controls, because it is a thing done to that field by someone already looking at it. It leads with the warning instead of burying it. The history of moves sits on the same drawer and renders nothing at all for the overwhelming majority of customers, who have never been moved.

Verified: backend tsc clean for src and tests, 526 unit tests pass, lint clean apart from warnings that predate this branch; frontend tsc, lint and build clean. The integration suite needs a database this machine has no Docker for. It also cannot be proven by CI right now — run 917 has been hung since it started and 24 runs are queued behind it, which is the same hang #154 identifies as the source of the leftover Postgres containers.

Closes #337

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:36:34 -05:00

137 lines
6.8 KiB
Markdown

# Account recovery
What happens when a customer cannot get into their account, and what the shop
can do about it. Written for #42, which exists because this is the piece most
likely to be skipped and most expensive to discover missing.
Every other issue in the passkeys project adds a capability. This one is the
safety net.
## The short version
| The customer has lost | They can recover by | Self-service |
| --- | --- | --- |
| Their password | A reset link emailed to them | Yes |
| Their passkey or the device holding it | Signing in with their password | Yes |
| Every passkey and their password | A reset link emailed to them | Yes |
| Access to their email address | Contacting the shop, which moves the account | No |
The email address is the root of trust. Every self-service route above ends at
it, and none of them can work without it.
## A password reset removes every passkey
This is the decision #42 existed to make, and it is deliberate.
A reset is the recovery path, and recovery has to be complete. The reset already
deletes every session on the account, on the reasoning that a reset prompted by
a compromise must not leave an intruder signed in for the remaining 30 days of
their cookie. A passkey an intruder registered has no expiry at all. Leaving
those behind would mean a customer can recover their password and still not have
their account back.
The obvious objection is that this lets whoever controls the mailbox strip a
customer's passkeys. It does, and it costs nothing: anyone who can complete a
reset already controls the email address and therefore already controls the
account. The passkeys were not protecting anything by that point.
Consequences worth knowing:
- The customer is told before they act. The reset email and the reset form both
say it, unconditionally. The form is not signed in and is never told whether
the account has passkeys, because answering that would make the reset page an
oracle for it.
- The customer is told after they act, with a count, and only when the count is
more than zero. This is the one moment that count can be reported: the rows
are gone by the time anyone could go and look. A customer told two were
removed who only remembers registering one has just learned something they
could not otherwise find out.
- Any WebAuthn challenge in flight goes too. An intruder who pressed "add a
passkey" moments before the reset could otherwise finish the ceremony
afterwards and put a credential straight back.
## Changing a password does not remove passkeys
The asymmetry with the paragraph above is intentional.
`change-password` requires the current password from someone already signed in.
Nothing about that suggests a lockout or a compromise, and it already spares the
current session for the same reason. A customer who suspects one particular
device can revoke that device by name from the account page, which is a better
tool than deleting everything.
A reset has no idea which credential is the problem, so it takes all of them. A
change knows the customer is present and in control, so it takes none.
## Losing the authenticator is not a lockout
A customer who loses the phone or key holding their passkey signs in with their
password as normal, and revokes the lost credential from the account page. This
needs no support involvement and no new capability, which is why #42 implements
nothing for it.
This holds only while every account has a password. It stops holding when #332
lands social sign-in, which creates the first customers with no password at all.
Their recovery route is the identity provider, not a reset link, and #332 owns
that question. The revocation guard in `backend/src/routes/passkeys.ts` already
refuses to delete a customer's only way in, written against that condition
rather than against today's schema, so it starts holding on its own the moment
the condition changes.
## Losing the email address is a lockout
There is no self-service recovery, and there should not be. Recovering an
account whose email is gone means proving identity some other way, and this shop
holds no other way — no phone number, no security questions, no identity
documents. Anything invented to fill that gap would be a weaker credential than
the one it replaces, and would become the easiest way to take an account over.
The route is manual, and it runs through the shop owner:
1. The customer makes contact by whatever means they have.
2. The owner verifies them against order history — items bought, dates, the
shipping address on file. A stranger has none of that.
3. The owner opens the customer in Admin, Customers, and uses **Move to a new
address** on the detail drawer.
Step 3 was added by #337. Before it existed the only answer was a database edit
by hand, which left no record of who did it or why.
### What the move does, and why
Say plainly what it is: **this operation and an account takeover are the same
operation.** They differ only in whether the verification in step 2 was sound,
and nothing in the software can check that. Everything the move does is aimed at
that fact.
- **It asks for a written reason, and refuses without one.** The reason is
recorded against the account and never shown to the customer. It is the only
thing that distinguishes a genuine recovery from a takeover afterwards.
- **It emails the address being replaced.** If the recovery was sound this
reaches nobody, which costs nothing. If it was not, it reaches the real owner,
who is the only person who can say so. That mail has its own wording, because
the self-service notice says "if you did not make this change, contact us" and
here somebody already did.
- **It sends a confirmation link to the new address and marks it unverified.** A
customer reading an address out over the phone has not demonstrated they can
receive mail at it. This is the commonest way the move goes wrong harmlessly.
- **It signs the customer out everywhere, removes every passkey, and cancels
outstanding reset links.** Same reasoning as a password reset, with more
force: somebody the system cannot identify asked for this, so a session or
credential surviving it is one the new owner cannot see or revoke, and a reset
link sitting in the old mailbox would let whoever reads it take the account
straight back.
- **It does not change the password.** What the customer lost was the mailbox,
not the password, so demanding a new one adds a step for no gain.
The history of moves on an account is shown on the same drawer, with the reasons.
It renders nothing at all for the overwhelming majority of customers, who have
never been moved.
### What it still does not do
There is no per-admin identity to record. Admin access is one shared gate secret
in front of a single operator, so a "who" column could only ever hold a constant,
and a constant dressed up as an identity is worse than an honest absence. If
per-admin identity ever arrives, the record gains a column then.