Identity Recovery (M-of-N Guardian Flow)
Reference for the social recovery flow: guardian lifecycle, threshold semantics, atomic approval, and the security guarantees enforced in shield-recovery.
Overview
Account recovery is M-of-N. An owner nominates guardians and a threshold; recovery completes when that many confirmed guardians approve. Two audiences share the flow: an owner manages guardians and starts a recovery, and a guardian confirms an invitation and votes.
Reachability. Console only. shield-recovery is not in the public allowlist, so https://api.passkeybridge.io/v1/shield-recovery answers 404 with x-pb-reason: unknown-function. It is called from the dashboard with a signed-in session through supabase.functions.invoke.
Authorization. A user JWT. No plan gate; the Recovery tab is available on every plan. Every action resolves the caller's tenant from their membership before any read or write, and a caller with no membership gets 404 No tenant found for user.
Guardian identity. A guardian's email address is never stored. It is lowercased, trimmed and hashed with keyed HMAC-SHA-256 under the server-held pepper into guardian_email_hash. Recognition at confirm and approve time works by re-hashing the email asserted by the caller's JWT and matching against the stored value, across the current pepper, the previous pepper during a rotation window, and a legacy unkeyed digest for guardians enrolled before keyed hashing. The plaintext address is used once, to send the invitation email, and then discarded.
Read this before you rely on approvals. The approve path does not work today; the section on the approval path explains exactly what happens and what the reader will see.
Actions
One endpoint dispatched on action. Nine actions, four of them guardian-facing.
| Action | Audience | Required fields | Returns |
|---|---|---|---|
add_guardian | Owner | guardian_email, optional threshold | { guardian, email_hash } |
list_guardians | Owner | none | { guardians }, all non-revoked rows for the caller |
remove_guardian | Owner | guardian_id | { removed: true } |
list_pending_invitations | Guardian | none | { invitations } addressed to the caller's email |
confirm_guardian | Guardian | optional guardian_id | { confirmed, count } |
initiate_recovery | Owner | none | { recovery_request } |
list_approvable_recoveries | Guardian | none | { requests }, up to 50 pending requests for owners the caller guards |
approve_recovery | Guardian | request_id | { approved, approvals, threshold, recovery_complete } |
recovery_status | Owner | none | { requests }, the caller's last 5 |
Any other value answers 400 Unknown action. A missing Authorization header answers 401 Missing authorization header, and a token that does not resolve answers 401 Invalid auth token.
`add_guardian` writes the row in pending status, assigns share_index as one more than the caller's current non-revoked guardian count, and sends the invitation email through the mail provider. The email is best effort: a mail outage is logged and the guardian row is still created. Adding the same address twice while the first invitation is still pending answers 409 Guardian already added.
`confirm_guardian` is what turns pending into confirmed. The caller must be signed in as the invited address and that address must be verified; an unverified account answers 403 Verify your email address before confirming guardian invitations, and an account with no email answers 403 Your account has no email—cannot confirm guardian invitations. Without guardian_id it confirms every pending invitation addressed to that email. Each confirmation writes an audit row of recovery.guardian_confirmed and binds guardian_user_id to the caller.
`list_pending_invitations` and `list_approvable_recoveries` return no owner identity beyond the opaque owner_user_id, and both are scoped to invitations and owners the caller already controls the email for.
Recovery tab
Dashboard, Recovery. The tab loads list_guardians, list_pending_invitations, list_approvable_recoveries and recovery_status in parallel, and each list carries its own error card so a failed load never renders as an empty list.
- Add guardian. Opens a dialog with a Guardian email field and an Approvals required to recover field, minimum 2 and maximum 10. Send invitation calls
add_guardian. The dialog states that only a keyed hash of the address is stored. - Guardian invitations. Shown to the invited person. Confirm calls
confirm_guardianfor that invitation. - Recoveries awaiting your approval. Shown to a confirmed guardian, with the current count as
N of M approvals. Approve callsapprove_recovery. - Guardians. One row per guardian as
Guardian [share index]with a Confirmed, Pending or Revoked badge, the dates added and confirmed, and a remove control behind a confirm dialog. - Recovery. Start recovery is enabled only once at least two guardians are confirmed. The card shows when the last request was started and its status badge.
- Recovery request history. The caller's last five requests with the approvals each one needs.
Guardian email addresses never appear anywhere in this tab, by design: the platform holds only the digest, so guardians are identified by slot number and status.
Threshold semantics
The threshold is an integer between 2 and 10, inclusive, set per guardian at add_guardian time and defaulting to 2. A threshold of 1 is rejected, because a single guardian able to recover an account alone removes the M-of-N property. Anything outside the range answers 400 threshold must be an integer between 2 and 10.
At initiate_recovery the canonical threshold for the request is the maximum across all confirmed guardians' configured thresholds, floored at 2. Taking the maximum means a mismatched guardian set is governed by the strictest policy in force rather than by whichever row happened to be read first.
Two preconditions are checked before the request row is created, both answered as 412:
No confirmed guardians configuredwhen no guardian has reachedconfirmed.Threshold <N> exceeds confirmed guardian count <M>when the resolved threshold could never be met.
Both describe a misconfigured guardian set rather than an authorization failure, which is why they are 412 rather than 403.
The resolved threshold is captured on the request as threshold_required, so guardians added or removed afterwards do not change what an open request needs.
Approval path
Approving a recovery fails today. A guardian who clicks Approve sees the toast Failed to record approval, and the request stays at its current approval count. This is a defect in the platform, stated here so the behaviour is not a surprise; it is not a configuration problem on your side and there is nothing you can change to work around it.
What happens. The function performs its own checks first, and those work: the request must exist and be pending, otherwise 404 Recovery request not found or already completed; the caller must have an email on the account, otherwise 403 Your account has no email—cannot verify guardian status; that email must be verified, otherwise 403 Verify your email address before approving a recovery; and the caller must be a confirmed guardian for the request's owner, otherwise 403 You are not a confirmed guardian for this account.
Past those checks the vote is recorded by a single database routine, recovery_atomic_approve, invoked with the service role. That routine opens by requiring auth.uid() to be non-null and to equal the approver it was passed. A service-role call carries no end-user identity, so auth.uid() is null, the routine raises forbidden: approver mismatch, and the function turns that into 500 Failed to record approval. The guard was added as defence in depth for a caller that authenticates as the user; the caller is the service role.
The routine is otherwise sound, and describing it is still worth doing because it is what will run once the callers agree. Inside one transaction it locks the request row with SELECT FOR UPDATE, checks for an existing recovery.approve audit row from this approver and returns { ok: false, reason: "duplicate_vote" } if it finds one, increments approvals_received, writes the audit row that also serves as the duplicate marker, and flips the request to completed when the count reaches threshold_required. That single transaction is what closes both the lost-update race between simultaneous voters and the gap between completing a recovery and recording who completed it.
On the client, treat recovery_complete: true as the only signal that the threshold was met. Do not derive completion from the approval count.
The rest of the flow is unaffected: adding, confirming, listing and removing guardians all work, and a recovery request can be created and seen by its guardians.
Data model
Two tables, both scoped by tenant and protected by row-level security that limits a row to its owner and its guardian.
`shield_recovery_guardians`, one row per guardian per owner.
| Column | Meaning |
|---|---|
owner_user_id | The account being protected |
guardian_user_id | Bound at confirmation, and the identity the approval routine checks |
guardian_email_hash | Keyed HMAC-SHA-256 digest of the lowercased, trimmed address |
threshold | This guardian's configured approval requirement, 2 to 10 |
share_index | Slot number, assigned at insert as the current non-revoked count plus one |
status | pending, confirmed or revoked |
confirmed_at, revoked_at | Lifecycle timestamps |
`shield_recovery_requests`, one row per recovery attempt.
| Column | Meaning |
|---|---|
owner_user_id | Who started it |
threshold_required | Captured at initiation so later guardian changes do not move the bar |
approvals_received | Incremented by the approval routine |
status | pending, then completed |
expires_at | Defaults to 72 hours after creation |
completed_at | Set when the threshold is reached |
expires_at is stored and never read. No code path expires a pending request and no scheduled job closes one, so a request that is never approved stays pending indefinitely. Treat the 72 hours as a record of intent rather than an enforced deadline.
share_index is a display label. Nothing splits a secret across guardians today; approval is a counted vote, not a share reconstruction.
Client example
From a signed-in dashboard client. There is no API-key lane, and no /v1 URL.
import { supabase } from "@/integrations/supabase/client";
// Owner: add a guardian. Threshold defaults to 2, range 2 to 10.
await supabase.functions.invoke("shield-recovery", {
body: { action: "add_guardian", guardian_email: "trusted@example.com", threshold: 3 },
});
// Guardian, in their own session: find and confirm the invitation.
const { data: inv } = await supabase.functions.invoke("shield-recovery", {
body: { action: "list_pending_invitations" },
});
await supabase.functions.invoke("shield-recovery", {
body: { action: "confirm_guardian", guardian_id: inv?.invitations?.[0]?.id },
});
// Owner: start a recovery once enough guardians are confirmed.
const { data: init } = await supabase.functions.invoke("shield-recovery", {
body: { action: "initiate_recovery" },
});
const requestId = init?.recovery_request?.id;
// Guardian: see what they can approve.
const { data: approvable } = await supabase.functions.invoke("shield-recovery", {
body: { action: "list_approvable_recoveries" },
});
// Guardian: vote. Returns 500 "Failed to record approval" today.
await supabase.functions.invoke("shield-recovery", {
body: { action: "approve_recovery", request_id: requestId },
});
// Owner: poll the last five requests.
const { data: status } = await supabase.functions.invoke("shield-recovery", {
body: { action: "recovery_status" },
});The client returns { data, error }, and the function also reports failures as an error field inside data, so check both.
Operational notes
- No address at rest. The only persisted identifier for a guardian is the keyed HMAC-SHA-256 digest of their lowercased, trimmed email under the server-held pepper. It is recomputed on every request and never stored in a form that can be reversed or looked up backwards.
- Removal is non-destructive.
remove_guardiansetsrevokedwith a timestamp and leaves the row for audit. A revoked guardian no longer counts toward the confirmed pool and cannot vote. - Confirmation needs a verified email. The guardian signs in with the invited address and confirms from the Recovery tab. The verified-email JWT is the proof of address control, so there is no separate confirmation token.
- Tenant isolation comes from membership. Guardians are scoped per owner, and every action resolves the caller's tenant from
shield_tenant_membersand writes it on every row, so no query escapes the configured policies. - Verify a guardian was added: Dashboard, Recovery, Guardians shows a new
Guardian [n]row with a Pending badge, and the invited person sees it under Guardian invitations once they sign in. After they confirm, the badge reads Confirmed and the card above reports the new confirmed count. - Verify a recovery was started: Recovery request history shows a new entry with the approvals it needs, and each confirmed guardian sees it under Recoveries awaiting your approval.