Skip to content

fix(auth): let the app delete an account that has a second factor - #888

Merged
MBombeck merged 3 commits into
mainfrom
fix/account-delete-bearer-mfa
Sep 2, 2026
Merged

fix(auth): let the app delete an account that has a second factor#888
MBombeck merged 3 commits into
mainfrom
fix/account-delete-bearer-mfa

Conversation

@MBombeck

@MBombeck MBombeck commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Apple requires that an account can be deleted from inside the app (guideline 5.1.1(v)). For an account with a second factor, ours could not be.

Both erasure routes gate on requireFreshMfaIfEnrolled, which resolves to requireFreshMfa, which is cookie-only by construction. A Bearer token can never satisfy it, so a user with an authenticator app or a security key had no way to delete their account or wipe their data from the phone. Without a second factor it already worked.

The fix uses the step-up ceremony this repo already has rather than accepting a code in the request body. POST /api/auth/step-up mints a single-use, token-bound elevation valid for five minutes against a freshly proved factor, and a structural test freezes "one mint surface, one redemption path". The two erasure routes join the set that accepts an elevation, through a new requireFreshMfaOrElevationIfEnrolled beside the old gate. requireFreshMfa itself is untouched and stays cookie-only. The native app already speaks this ceremony for managing two-factor, so nothing new has to be learned on that side.

The wire contract, for the client: mint at /api/auth/step-up, then send X-Step-Up alongside the Bearer token on the delete. A missing, expired, spent, foreign or too-weak elevation all answer 401 with the same meta.errorCode, deliberately indistinguishable. A narrow-scope token is still refused before any factor check.

Recovery codes are not accepted on this path: the mint refuses them by design, because spending a break-glass credential to authorise another one is the wrong trade, and widening it would also open disabling two-factor. Someone who has lost their authenticator erases from the web.

Tests, each written red first: the new gate (13 cases), the structural guard extended to name both routes, and an integration test on real Postgres that enrols a factor, mints an elevation over Bearer and deletes end to end (9 cases). Gate: typecheck, lint, prettier, full unit suite, four integration files, openapi in sync.

Account deletion and the record wipe both demand a fresh second-factor
proof once a factor is enrolled, and that proof lived on the session row.
A native client has no session row, so an account with TOTP or a security
key could not delete itself from the app on any request it could build:
requireFreshMfaIfEnrolled fell through to requireFreshMfa, which resolves
via getSession and answers 401 to a perfectly valid token. App Store
guideline 5.1.1(v) asks for exactly that deletion.

Add requireFreshMfaOrElevationIfEnrolled beside the cookie-only sibling
rather than softening it. Its cookie arm is the sibling's, unchanged;
its Bearer arm takes the single-use elevation the second-factor
management routes already accept, with the fresh-factor rule pinned on,
so a password-proved elevation cannot erase an account any more than a
password login satisfies the gate on the web. No factor rides the request
body: one mint surface, one redemption path. Scope stays fail-closed, so
a narrow token is refused before the header is read.

The elevation is spent immediately before the erasure, after the typed
confirmation and the last-admin check, so a rejected request does not
burn a proof against a 5-per-15-minute mint ceiling. The Bearer branch
of requireMfaManagementAuth moves into a shared helper so both gates read
and claim in the same place.

The elevation guard grows a section naming the two routes, because the
set of surfaces an elevation can unlock only stays small if adding to it
is a visible edit.
The published contract still told a client that a Bearer token can never
clear the step-up on account deletion or the record wipe, which is what
an app generates its client from. Describe the elevation arm on both:
which factors qualify, that a password proof does not, that the proof
rides the X-Step-Up header, and that it is spent only when the erasure
runs. The 403 a narrow-scope token gets was never published either;
it is named now, ahead of the second-factor question it precedes.

The mint endpoint lists the surfaces an elevation unlocks, so both
routes join that list.

Also note on the record wipe that API tokens are on its own delete list:
a client that wipes over Bearer signs itself out, which is worth knowing
before the retry rather than after it.
@MBombeck
MBombeck merged commit 53689ee into main Sep 2, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant