Sealed Identity Architecture โ Decision Brief โ
Status: Approved direction. Implementation in two stages (ยง4); legal documentation runs in parallel (ยง6), not as a gate. See ยง11 (2026-06-15) for the committed end-state direction (custom-token signup bootstrap + full Stage B), the verified Firebase-hardening research, and the loss/recovery model. ยง11.7 (2026-06-22) tracks the current implementation status (the OTP bootstrap is built and dormant behind a flag).Owner: TBD (privacy lead). Companion plan: docs/planning/plans/2026-05-10-sealed-identity-spike.md.
Posture: Architectural privacy protections are not subject to legal-review veto. The premise of this work is that if we cannot produce a piece of data due to how the system is built, we cannot be compelled to rebuild the system to produce it. That position is well-supported in US law (All Writs Act limits, Apple/FBI 2016) and is consistent with Lantern's Immutable Right #6 (cofounder agreement). Counsel's role here is to make sure our privacy policy, ToS, and subpoena-response playbook accurately describe the architecture โ not to grant or withhold permission to ship it.
1. Summary โ
Lantern's privacy commitments (Immutable Right #6, Business Plan ยง3.2) are met today by client-side profile encryption and k-anonymity gating, but the phone-number โ account-record link is server-resolvable in plaintext. A subpoena of the form "what account has phone +1-555-โฆ?" returns a userId directly. This document proposes a two-stage hardening of that link, evaluates legal/operational/engineering cost, and lists trigger criteria for committing to it.
The brief is intentionally narrow: it covers the auth-table identity link only. Profile data sealing (PBKDF2 + AES-GCM, apps/web/src/lib/encryption.js) is already in place and out of scope.
2. v1 reality (what the code actually does today) โ
Earlier drafts of this brief described a v1 with HMAC-hashed phones, a
login_eventstable, and abanned_phone_hashesindex. None of those exist. This section is the authoritative description.
2.1 Storage โ
Phone-PIN users live in the Firestore users/{userId} collection. Relevant fields:
| Field | Contents | Sensitivity |
|---|---|---|
phone | E.164-normalized phone number, plaintext | High โ direct PII |
phoneSalt | Per-user salt for client-side wrapping-key derivation | Public-by-design |
encryptedSeed | BIP39 entropy AES-GCM-wrapped with a phone+PIN-derived key | Sealed (we cannot decrypt) |
authProofHash | HMAC-SHA256(entropy, "lantern-auth-proof-v1") | Hash; verifier only |
pinFailedAttempts, pinLockoutUntil | Server-enforced lockout state | Operational |
encryptedBirthDate, encryptionCanary, salt | Profile encryption | Sealed |
lanternName, authMethod, lastLoginAt | Display + audit | Lowโmedium |
There is no auth_table distinct from users/{userId}. The phoneโuserId link is just users.where('phone', '==', normalized).limit(5) (services/api/auth/src/routes/phone.js:42).
2.2 Login flow (zero-knowledge proof of PIN, server-resolvable identity) โ
- Client
POST /auth/phone/lookupwith{ phone }. Server queriesusersby plaintextphone, returns{ userId, phoneSalt, encryptedSeed, lanternName, authMethod }. - Client derives wrapping key from phone+PIN, decrypts
encryptedSeedโentropy(16 bytes). - Client computes
proofHmac = HMAC-SHA256(entropy, "lantern-auth-proof-v1")andPOST /auth/phone/tokenwith{ userId, proofHmac }. - Server compares against stored
authProofHashwithtimingSafeEqual(customToken.service.js:46โ55). On match, issues a Firebase custom token; on mismatch, incrementspinFailedAttempts; locks out at 5 failures for 15 minutes.
What this already gets us: server cannot recover the PIN; encryptedSeed is not decryptable server-side; profile fields are sealed.
What this does not get us: step 1 returns userId indexed by plaintext phone. A compelled-disclosure request providing a phone number gets back the userId and the entire users/{userId} document (minus the sealed fields).
2.3 Login event logging โ
There is no central login_events collection. PIN attempt counters live on users/{userId} itself. Admin and merchant logins write to adminActions (no IP) (adminAuth.js:81โ84). User phone+PIN logins update lastLoginAt only. Cloud Run access logs (timestamps, IPs, request paths) exist outside Firestore at the platform layer.
2.4 Ban enforcement โ
User-level bans are implemented at the userId level only (moderation.js:22โ66) โ sets users/{userId}.banned, disables Firebase Auth account. No phone-hash ban table exists. The banned_accounts collection sketched in docs/features/safety/SAFETY_MECHANICS.md (bcrypt-hashed phone + email) is designed but unbuilt.
Update (2026-06-22): the
banned_accountstable is now BUILT and wired. It keys on HMAC-SHA-256 pepperedphoneHash/emailHash(the same KMS pepper as the user table, NOT bcrypt), viaisPhoneBanned/createBan/overturnBaninbannedAccounts.service.js, enforced at the OTPverify-otpandcreateUsergates (ยง11.7). A ban is a pure membership lookup on the hash of the incoming phone, so it is UID-independent and survives Stage B. The "unbuilt" sentence above describes the original-writing state.
3. The actual gap, in subpoena terms โ
| Question | v1 response |
|---|---|
| "Does an account exist for this phone?" | yes/no |
| "What's the userId for this phone?" | returns userId directly |
| "What did this user do?" | full users/{userId} doc + any userId-indexed records (frens, waves, lit-lantern events) |
| "Decrypt their profile" | cannot โ sealed |
| "Decrypt their seed / PIN" | cannot โ sealed |
The first three rows are the meaningful disclosure surface. Sealing the third (everything keyed by userId) is hard โ it's the operational data of the app. Sealing the first two is what this proposal addresses.
4. Proposal โ two stages, not one โ
The original brief jumped straight to encrypting the link. That skips the bigger and cheaper win.
Stage A โ Hash the phone (no behavior change for users) โ
Replace plaintext users/{userId}.phone with phoneHash = HMAC-SHA256(KMS_pepper, e164(phone)). Lookup becomes users.where('phoneHash', '==', clientOrServerComputedHash).limit(5). KMS pepper rotation strategy TBD in spike.
This alone:
- Removes plaintext PII from the dominant subpoena entry path
- Forces a compelled-disclosure request to either provide the pepper-hashed value (which they cannot, without our KMS access) or compel us to compute it (legally distinguishable from "produce the row")
- Unblocks the
banned_accountsdesign inSAFETY_MECHANICS.md(same hash form) - Doesn't touch the proof-of-entropy chain
This is roughly the v1 the original brief thought we already had.
Stage B โ Seal the userId resolution (the original proposal, restated) โ
After Stage A, users/{phoneHash โ userId} is still server-resolvable: phone-with-pepper โ row โ userId. Stage B encrypts the userId itself with a passphrase-derived key:
auth_lookup: (phoneHash, encryptedUserIdBlob, phoneSalt, encryptedSeed, authProofHash)
encryptedUserIdBlob = AES-256-GCM(userId, key = HKDF(entropy))The login flow gains one step:
POST /auth/phone/lookupreturns{ phoneSalt, encryptedSeed, encryptedUserIdBlob, authProofHash }โ nouserId.- Client decrypts
encryptedSeedwith PIN โ derivesentropyโ derives blob key via HKDF โ decryptsencryptedUserIdBlobโ hasuserId. - Client sends
{ userId, proofHmac }to/auth/phone/tokenas today.
Server never observes phoneHash โ userId resolution as a single readable step. Compelled disclosure of the auth_lookup row yields a hashed phone and an opaque blob.
Trade-offs vs. v1:
| Capability | Stage A | Stage B |
|---|---|---|
| Subpoena "userId for phone X" | hash + row, still resolvable | ciphertext only |
| CS lookup by phone | unchanged (compute hash, query row) | requires user-initiated session sharing |
| Anti-fraud on phone-reuse | unchanged | reduced; phone-hash visible but cannot link to userId activity |
banned_accounts enforcement | enables it | unaffected (independent index) |
| Login UX | unchanged | one extra round-trip's worth of decryption (sub-50ms client-side) |
| Engineering scope | medium (~1 sprint) | high (multi-sprint, plus migration) |
5. Trigger criteria โ
Stage A: should land before the Beta release per README.md, and ideally before Alpha puts real phone numbers in the system at all. Plaintext phones in production are difficult to walk back once we have real users.
Stage B: should also land before the Beta release if engineering capacity allows. The case for sealing pre-launch:
- Sealing the architecture before the first subpoena lands looks like a privacy commitment; sealing it after looks like obstruction.
- Migrating existing users is harder than building it for new users โ every month of pre-launch growth raises the migration cost.
- Public privacy claims (privacy policy, marketing) made under a v1 architecture become legal liabilities if we then change the architecture and the claims drift.
If engineering capacity forces a deferral, Stage B can ship in a Phase 4โ5 patch window or, at the latest, Phase 6.
Market-entry triggers that would force Stage B regardless of timing:
- Move into a jurisdiction with compelled-redesign powers (UK IPA, Australia TOLA). Those jurisdictions can order us to log decrypted data going forward โ the only architectural defense is to ship Stage B before market entry, so the absence of a logging mechanism is the pre-existing state of the system, not a post-hoc retreat.
- A merchant, governmental partner, or pilot partner requiring sealed identity as a contracting condition.
- A material privacy incident at a comparable platform implicating phoneโuserId resolution.
6. Open questions for review โ
Counsel (parallel to implementation, not a gate) โ
These are documentation/policy tasks. They produce artifacts that describe the shipped architecture; they do not grant or withhold permission to ship it.
- Privacy policy + ToS language. Audit the description of phone-number handling and identity resolution against what the code actually does post-Stage-A and post-Stage-B. Avoid claims that overstate sealing (e.g. "we never see your phone number" โ false; we see it transiently to compute the hash) or understate it (e.g. silence on the userId-blob means we lose the marketing benefit).
- Subpoena-response playbook. A prepared template for the most common request shapes:
- "What account has phone X" โ post-Stage-A: we can compute the hash and return whether a row exists, but the row contains no plaintext PII; post-Stage-B: we can return the blob but cannot decrypt it.
- "Decrypt this user's profile" โ cannot, by design.
- "Log future activity for phone X" โ covered separately; see question 5.
- User notification policy. When a subpoena identifier (phone) cannot be linked to a userId server-side, what does notification mean? Possible answer: notify all users via a transparency report, since we cannot identify the specific user.
- GDPR DPIA if/when EU market entry is on the roadmap. Sealed identity helps the DPIA, not hurts it โ but the document needs to exist.
- Compelled-redesign jurisdictions. Document which markets we will and won't enter without a Stage B equivalent already shipped (UK, Australia are the obvious risks). This is a market-entry checklist, not an architecture question.
- Pseudonymization classification under GDPR Art. 4(5): does HMAC-SHA-256 with a KMS-held pepper qualify? (Likely yes; document the answer.)
Engineering โ
- Session token lifecycle and rotation under passphrase-derived keys (how do background refreshes work without re-prompting for PIN?)
- Recovery flow when user clears app data but retains passphrase / recovery phrase
- Migration path for existing users (re-encrypt at next login? batch backfill via a one-time client task?)
- Anti-fraud detection redesign for
userId-only signals (ban-evasion via phone reuse โ does the phone-hash ban list cover the gap?) - Performance impact: extra round-trip for blob fetch + client-side decryption at every login (likely negligible but should be measured)
- KMS pepper rotation: how do we re-hash without a phone-number table to iterate over? (Likely answer: lazy re-hash on next successful login, with both old and new pepper accepted during a rotation window.)
- App Check / IP rate limiting: does sealing
userIdforce changes to abuse heuristics that currently key onuserId?
Operational โ
- Customer support workflow redesign โ read-only support views? user-initiated session sharing? out-of-band confirmation?
- T&S investigation tools that don't rely on phoneโuserId linkage
- Internal-access audit policy for
encryptedUserIdBlob(who can read what under what authorization) - Incident response runbook update โ what does "a user's phone was leaked" look like when we can't connect it to their
userId?
7. Non-negotiable constraints โ
Any proposal must respect:
- Immutable Right #6 (no data sales) โ cannot be voted away, not even unanimously
- ยง3.2 k-anonymity (โฅ 3 unique users per reported cell) on any merchant-surfaced metric
- No per-user behavioral profiles for ad targeting (Right #6 + ยง3.2)
- No cross-device tracking, no device fingerprinting
- Cannot weaken existing privacy commitments to gain operational capability
- Phase 1 capital posture (Cofounder Agreement ยง11): work must be deliverable on founder time + minimal infrastructure spend until Phase 2 trigger
8. Recommended next step โ
- Stage A (hash the phone) ships first. Removes plaintext PII, unblocks
banned_accounts, ~1 sprint. See the spike plan. - Stage B (seal the userId) ships immediately after Stage A โ preferably pre-Phase-5. The architectural commitment is strongest when made before the first subpoena and before public-facing growth.
- Counsel-track work runs in parallel (privacy policy + ToS audit, subpoena playbook, DPIA prep). It produces artifacts that describe what shipped; it does not block what ships.
- This brief is the canonical reference. Older drafts pointing to
docs/economics/AD_PLACEMENT_ECONOMICS.mdare dead links โ that file does not exist.
9. Subpoena flow under the end-state architecture โ
Correction (2026-06-15, see ยง11.1 and ยง11.5): this flow omits a second phone-to-UID copy held by Firebase Auth itself. The
signInWithPhoneNumbersignup bootstrap stampsphoneNumberonto the Auth UserRecord (resolvable via Admin SDKgetUserByPhoneNumber), and Firebase Auth has no CMEK/EKM support, so it cannot be sealed by encryption. "No path from phone to userId" holds only AFTER that bootstrap is replaced with external OTP and existing Auth records are scrubbed. The flow also describes the at-rest/historical case only; the in-use login moment and prospective-compulsion residuals remain (see ยง11.5).
This is what happens when law enforcement hands us a phone number and asks "who is this user?" once Stage A + Stage B are both shipped. The diagram is the answer to that question.
Subpoena: "who is the user with phone +1-555-XXXX?"
โ
โผ
(1) Normalize the input to E.164
โ
โผ
(2) Fetch PHONE_HASH_PEPPER from KMS / Secret Manager
โ
โผ
(3) phoneHash = HMAC-SHA-256(pepperBytes, e164)
โ
โผ
(4) Query auth_lookup by phoneHash
โ
โโโโถ row does NOT exist: truthful response = "no such account" [sealed]
โ
โผ (row exists)
(5) Row = { encryptedUserIdBlob, phoneSalt, encryptedSeed }
โ
โผ
(6) Can the server decrypt encryptedUserIdBlob?
โ
โโโโถ NO: truthful response = "opaque ciphertext returned; [sealed]
no path from phone to userId"
why NO: blob key = HKDF(entropy); entropy is recoverable only by
decrypting encryptedSeed with the user's PIN, which the server
never sees and cannot derive.
userId .......... never recovered server-side [ghost]Why each step is irreversible โ
| Step | What we have | What we don't have |
|---|---|---|
| Hash the phone | The pepper (in KMS/Secret Manager) and the input phone | A reverse โ HMAC isn't reversible; without the pepper, even a leaked DB dump can't be rainbow-tabled against a phone-number dictionary at scale |
| Look up the row | The phoneHash to query against | The userId of the row owner โ Stage B replaces the userId field with a ciphertext blob |
| Decrypt the blob | The ciphertext, returned by Firestore | The decryption key โ it's HKDF(entropy), and entropy comes only from decrypting encryptedSeed with the user's PIN. The PIN never leaves the user's device, and we don't store it in any form (only authProofHash, an HMAC of the entropy under a fixed context string, which is one-way) |
How the phone hash works, forward and back โ
The forward path turns a phone into the stored phoneHash; the reverse question is "who, if anyone, can turn that back into the phone?" The short answer: only the holder of the pepper, and even they hit a wall at Stage B.
FORWARD: phone -> phoneHash (what we actually store)
+1-619-555-0100 PHONE_HASH_PEPPER
(the phone) (secret; lives in KMS, never in the DB)
โ โ
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโ
โผ
โโโโโโโโโโโโโโโโโโโโโ
โ HMAC "blender" โ one-way + deterministic:
โ (no reverse fn) โ same inputs = same hash
โโโโโโโโโโโโโโโโโโโโโ
โผ
phoneHash = v1:9f3a...e2 <- the only thing kept
โ
โโโโบ ban check (re-blend a phone, compare)
โโโโบ already-registered? (same compare)
โโโโบ find the account rowREVERSE: can you get the phone back out of phoneHash?
who is asking?
โ
โโโโโโโโโโโโโดโโโโโโโโโโโโ
โผ โผ
leaked DB, NO pepper has the pepper (us)
โ โ
โผ โผ
can't run the blender brute-force: hash every
-> DEAD END possible phone (~10^9) and
(nothing to test against) match. NOT "decoding" -
just rebuilding the table.
โ
โผ
phone <-> phoneHash
โ
โผ
account row
โโโโโโโโโโโโโดโโโโโโโโโโโโ
โผ โผ
STAGE A (today) STAGE B (sealed)
row gives the userId userId is a blob,
-> FULL resolve locked by the PIN
-> only "an account
EXISTS?", never
who or whatThe takeaway: the pepper is the only lock on the forward arrow (no pepper, you cannot even start), and Stage B adds a second lock so that even with the pepper the reverse trip stops at "an account exists" (the existence oracle, ยง11.5 residual #5) instead of "here is who and what."
What we hand over vs. what we don't โ
| Subpoena asks | What we can produce | What we cannot produce |
|---|---|---|
| "Does an account exist for phone X?" | yes / no | โ |
| "What's the userId for phone X?" | the row's ciphertext blob | the userId itself |
| "What did userId Y do?" (if they hand us the userId) | everything indexed by userId | profile fields (still PBKDF2 + AES-GCM client-encrypted) |
| "Decrypt this user's profile" | nothing | profile bytes (we never had the key) |
| "Decrypt this user's seed" | nothing | seed bytes (PIN-wrapped, we never had the PIN) |
What each stage contributes โ
| Stage | Status | What it adds | What still leaks without it |
|---|---|---|---|
| Stage A โ hash the phone with KMS pepper | Phase 1+2 shipped on dev (PR #479); phase 3-5 pending | Removes plaintext phone numbers from the database. A leaked Firestore export becomes useless without the pepper. Enables banned_accounts to share the same hash form. | Plaintext phone field still present during phases 1-2 (dual-write); the phoneHash โ userId resolution is still server-side once a phone is provided to the server. |
| Stage B โ encrypt the userId resolution | Committed (ยง11.0); not yet built. The OTP bootstrap that precedes it is built and dormant (ยง11.7) | Replaces the userId in the auth lookup row with a passphrase-keyed ciphertext blob. The server can find the row via phoneHash, but the row no longer reveals userId server-side. Only the user, by entering their PIN, can decrypt it. | Without Stage B, a subpoena providing a phone produces the corresponding userId and any data indexed by it. |
Important caveat about the current state โ
We have shipped Stage A phases 1-2 only (dual-write of phoneHash alongside plaintext phone). Today, a subpoena providing a phone number can still be answered with a userId: the server can either query by plaintext phone (fallback path) or compute the hash and query by phoneHash. Either path returns the row, and the row contains the userId. The diagram above describes the end-state after Stage A phase 4 (drop plaintext) and Stage B (seal the userId blob) both ship.
The interim state still meaningfully reduces certain attack surfaces โ a leaked DB dump (without the pepper) is much harder to rainbow-table than the previous plaintext-phone state โ but it does not yet make the phoneโuserId link unrecoverable. The diagram is what we are building toward, not what is live today.
10. References โ
- v1 auth code:
services/api/auth/src/routes/phone.js,services/api/auth/src/services/customToken.service.js - Profile encryption:
apps/web/src/lib/encryption.js - Existing privacy docs:
HOW_ENCRYPTION_WORKS.md,PRIVACY_PRESERVING_DATA_COLLECTION.md - Safety/ban design:
docs/features/safety/SAFETY_MECHANICS.md - Roadmap:
docs/business/launches/README.md - Cofounder Agreement: Immutable Right #6 (no data sales), ยง9.4 (Mission Arbiter), ยง3 (Marketing & Offers Platform constraints)
- Business Plan: ยง3.1 (encryption), ยง3.2 (anonymity + k-anonymity), ยง12 (plaintext-metadata limits)
11. 2026-06-15 update: committed direction, Firebase-hardening research, recovery model โ
This section records decisions and verified research from 2026-06-15. It supersedes the "Stage B not started / capacity-gated" framing above, corrects an omission in ยง2-4 and ยง9, and answers several ยง6 open questions (Engineering Q2, Operational Q1).
11.0 Decision โ
- Commit to full Stage A + Stage B. Operator decision: "if we custom-token, go all the way." Stage B is now the committed end state, not merely capacity-gated.
- Stay on Firebase. Do not migrate off-platform; harden in place.
- Governing principle: prefer data loss over data leak. No server-executable recovery path (a backdoor is a leak surface). Self-custodied recovery only. This principle resolves the recovery, availability, and retention tradeoffs below.
11.1 Architectural correction: a second phone copy lives on the Firebase Auth UserRecord โ
Sections 2-4 analyze the Firestore users.phone field but omit a SECOND plaintext phone copy. Signup verifies the phone with Firebase's own signInWithPhoneNumber (apps/web/src/screens/auth/PhonePinSignup.jsx:285,392), which stamps phoneNumber onto the Firebase Auth UserRecord and assigns the Firebase UID. The server reads it back at phoneCreateUser.js:88. That copy:
- is resolvable via the Admin SDK (
getUserByPhoneNumber,getUser(uid).phoneNumber); - is held by Google and directly compellable (CLOUD Act);
- is NOT addressed by Stage A (which hashes the Firestore field) or Stage B (which seals the Firestore blob); and
- can NEVER be encrypted away, because Firebase Auth has no CMEK/EKM support (11.2).
The fix is narrow, because login already uses custom tokens (customToken.service.js:187, createCustomToken). The change is to the signup BOOTSTRAP only: replace signInWithPhoneNumber with an external OTP verification, mint the custom token with an app-controlled (random, non-phone-derived) UID, and scrub phoneNumber from existing Auth records (updateUser({ phoneNumber: null }); exact behavior to confirm). After that, the phone never touches the Auth record and the UID is app-controlled rather than Firebase-assigned.
11.2 Verified Firebase-hardening facts (two adversarial deep-research passes, primary Google sources) โ
- Firebase Auth / Identity Platform: NO CMEK and NO Cloud EKM (absent from the Cloud KMS compatible-services table). The Auth-record phone therefore cannot be made unreadable-to-Google at rest by any key; custom-token (never store it there) is the ONLY remedy. https://cloud.google.com/kms/docs/compatible-services
- Firestore, BigQuery, Cloud Logging, Secret Manager: CMEK + Cloud EKM + Key Access Justifications all supported. With Cloud EKM the key material lives outside Google ("never sent to Google") and you can DENY a decryption tagged
THIRD_PARTY_DATA_REQUEST(legal process). This is the strong at-rest, deny-the-court lever. https://cloud.google.com/firestore/docs/use-cmek, https://cloud.google.com/kms/docs/ekm, https://cloud.google.com/assured-workloads/key-access-justifications/docs/justification-codes - Firestore CMEK is at-rest only and can be set only at database creation (cannot retrofit; requires export/import into a new CMEK database).
- EKM relocates legal compulsion to the external key partner's jurisdiction (a relocation, not an elimination) and adds an availability dependency (Firestore goes offline ~1h if the external key is unreachable). Acceptable under lose-don't-leak.
- Confidential Space (the in-use / login-moment protection; Signal-SVR analog): PARTIAL. The hardware-attested key-release model is real (AMD SEV-SNP / Intel TDX, silicon-vendor signed). But the two strongest properties ("the operator provably cannot read" and "Google's hypervisor is fully excluded") did NOT survive adversarial verification. Treat as a reserve/triggered tier, not a launch requirement. https://cloud.google.com/confidential-computing/confidential-space/docs/confidential-space-overview
- CLOUD Act: data residency is NOT a disclosure shield (Google can be compelled for data in its control regardless of region). https://services.google.com/fh/files/misc/government_requests_for_cloud_customer_data_google.pdf
- Calibration vs Signal: Signal requires and stores phone numbers and its own server CAN resolve phone-to-account; its strength is minimization + enclaves + owning its infrastructure. So custom-token + a hash on our own data = "Signal-grade", and Stage B ("even we cannot resolve it at rest") is BEYOND Signal: real additional hardening, but optional rather than table stakes. We are choosing to do it anyway (11.0).
11.3 Loss, deletion, and recovery (no admin recovery power) โ
A consequence of Stage B that simplifies operations: once an account is sealed under the user's PIN, an admin cannot decrypt it either, so an admin "reset" grants nothing the user's own phone does not already grant. Recovery is therefore self-service, not admin-mediated:
- Lost PIN: the user still controls the phone. They re-verify via OTP, then choose: enter the PIN to recover, or start over (a fresh opaque UID + new PIN; the old record is purged). Start-over MUST check the ban-hash list, so a lost PIN cannot become a ban-evasion path.
- Start-over is not recovery. The old connections, chats, and saved frens are gone: the lose-don't-leak tradeoff made real. The UX must state this bluntly and should nudge users to save the recovery phrase at signup (the only thing that converts "lost forever" into "recoverable by you", via the existing
recoveryPhraseHash). - Deletion: the cascade frees the
phoneHashrow; the user can re-register fresh later via OTP. No admin step. - Admin involvement is reserved for genuine edge cases only: recycled-number disputes (
phoneReclaims), fraud/abuse investigations, and legal holds, not routine PIN loss. (This answers ยง6 Engineering Q2 and Operational Q1.)
11.4 OTP provider and cost (research 2026-06-15; re-verify pricing at purchase) โ
The custom-token bootstrap (11.1) needs an EXTERNAL OTP provider to perform the phone verification signInWithPhoneNumber does today, so the phone never reaches the Auth record.
Stakes reframe (important): under custom-token + Stage B, the provider NEVER sees the phone-to-UID link. It sees only "phone X requested a code from Lantern at time T" (membership metadata), because the UID is minted separately and the provider is never told who the phone becomes. A subpoena to the provider yields membership metadata, not the phone-to-account map and not account contents. So provider choice optimizes a BOUNDED metadata leak + cost + fraud + deliverability, not the core seal.
Verified options (prices observed 2026-06-15, primary sources, re-verify before committing):
| Provider | Cost / verification | Fraud (AIT) | Jurisdiction / retention |
|---|---|---|---|
| Plivo Verify | ~$0.011-0.013 all-in ($0 verify + $0.0077 SMS + carrier surcharge) | Free Fraud Shield (active block); bills per-send, NOT success-only | US only, no EU residency; ~7-yr retention |
| Prelude | ~$0.035 (EUR 0.032, success-only) | 4-layer + OTP-pumping | EU-or-US hosting choice; SOC 2 Type II + ISO 27001 + GDPR; "never sells/shares"; exact retention TBD |
| Twilio Verify | ~$0.058 ($0.05 + $0.0083 SMS) | Fraud Guard bundled free | US; 13-month default retention; content requires a WARRANT (higher bar than subpoena) |
| Telesign | not priced | add-on | Belgian (Proximus/BICS), US-subpoenable, discloses to LE: AVOID |
Rough monthly cost (1 SMS/verification): at 500 / 2,000 / 10,000 verifications: Plivo ~$4 / $16 / $80; Prelude ~$18 / $70 / $350; Twilio ~$29 / $117 / $583. Plus US A2P 10DLC registration (a flat overhead on every provider; exact fees UNVERIFIED). Firebase Phone Auth's current per-verification price was UNVERIFIED, so the cost delta of leaving it is unconfirmed (but trivial in absolute terms at launch scale).
Recommendation (updated 2026-06-16; DECIDED 2026-06-22: Prelude): the front-runner is now Prelude (prelude.so), whose profile maps to Lantern's priorities better than Twilio's: an EU-jurisdiction hosting option, success-only billing (failed and pumping sends are on Prelude, structurally capping the dominant cost risk), 4-layer anti-fraud with OTP-pumping protection, SOC 2 Type II + ISO 27001 + GDPR, a stated "never sells or shares customer data," and ~$0.035 to $0.039 per successful verify (cheaper than Twilio). Reference customer BeReal (consumer-social, US footprint); a stated 99.5% deliverability SLA. DECIDED 2026-06-22 (operator): Prelude. The one open tradeoff was vendor MATURITY (Prelude a Series-A startup vs incumbents); the Plivo deep-dive below settled it, since the mature alternative loses EU residency and success-only billing, which outweigh vendor age for a privacy-first app. Twilio Verify stays the documented conservative fallback (mature, US-jurisdiction, warrant-for-content, ~$0.058) for use if Prelude wobbles or fails a pre-commit check. Plivo is cheaper (~3x all-in) and a far more durable vendor, but bills per-send (not success-only) and is US-only with ~7-year retention, so it loses Prelude's two differentiators (the AIT cost-cap and EU residency); see the Plivo deep-dive below. Avoid Telesign. Silent Network Auth is an optional fraud/UX fallback only (removes the SMS but adds a carrier lookup plus WiFi/MVNO coverage gaps).
Testing (a Prelude plus): the full custom-token integration + CI can be built and run FREE against test phone numbers (no SMS sent, not billed), with NO time-limited trial: Prelude is pure pay-as-you-go, no setup cost, no minimum ("Try Prelude with no commitments"). New accounts also get free credits for real-delivery testing, which conveniently covers pre-commit check #2 (the live US deliverability test). You only pay for REAL verifications beyond the free credits (EUR 0.032, success-only). Unconfirmed (worth asking): the cap on test numbers and the exact free-credit amount / expiry.
Before committing to Prelude, verify (3 checks): (1) the DPA: exact data-retention period + subprocessor list + EU-hosting confirmation; (2) a real US deliverability test in the launch market; (3) a deliberate vendor-longevity judgment (the Series-A risk). Plus current 10DLC + Firebase-baseline pricing. (Prelude deep-dive 2026-06-16 from prelude.so/pricing + /twilio-alternative; the trust center at trust.prelude.so did not load, hence retention TBD.)
Pre-commit questions for Prelude (check #1 detail; send to sales / security):
- Retention + deletion: exact retention period for (a) the phone number, (b) the OTP code, (c) verification metadata (timestamps / IP / device signals); is it configurable or shorter; on a user-deletion request can you purge all data tied to a phone number, and at what SLA.
- Residency: under EU hosting, exactly what is stored / processed in the EU vs elsewhere; can ALL processing be pinned to the EU; any US processing or transfer.
- Subprocessors: full list of subprocessors (SMS aggregators / carriers, cloud, fraud vendors) that touch the number or message content; advance notice of changes.
- Compelled disclosure: government / law-enforcement request policy (customer notification, required legal process, transparency report); which jurisdiction governs under EU hosting.
- Compliance: SOC 2 Type II report + ISO 27001 cert under NDA; GDPR Art. 28 DPA with SCCs.
- US deliverability: last-90-day delivery rate to AT&T / Verizon / T-Mobile + major MVNOs; who manages 10DLC registration and the lead time; what the 99.5% SLA covers (uptime vs delivery) and the remedy / credits.
- Fraud + billing: confirm success-only billing means we are NEVER charged for failed / fraudulent sends during a pumping attack; what the 4-layer anti-fraud includes and whether it is on by default; can we geo-restrict destinations (e.g. US-only) to bound pumping cost.
- Integration + testing: Node SDK + a custom-token exchange flow (we verify, then mint our own Firebase custom token); do test phone numbers return a settable fixed code (for deterministic CI), and is there a cap on how many we can register; how many free real-delivery credits do new accounts get and do they expire; confirm current pricing (EUR 0.032 success-only + the US SMS rate) and any platform / minimum fees.
Fraud note: SMS-pumping / AIT, not unit price, is the dominant cost risk. Gate OTP sends behind App Check / per-number + per-IP rate limits regardless of provider, and keep the provider's fraud protection on.
Plivo deep-dive (2026-06-22): cheaper and more durable, but it loses Prelude's two differentiators โ
A 7-angle due-diligence pass on Plivo Verify as a Prelude alternative (pricing, billing model, fraud, jurisdiction/retention, compliance, deliverability/10DLC, integration, longevity).
Verdict: do NOT switch on price + maturity alone. Plivo strictly wins the two axes that motivated the look (price and vendor longevity) but loses the two that motivated CHOOSING Prelude (success-only billing and EU residency). Keep Prelude as primary IF EU jurisdiction is a hard requirement; Plivo becomes a serious contender only if that requirement softens AND the pre-commit answers below come back clean in writing.
Where Plivo wins
- Cost: ~$0.011 to $0.013 all-in per US verify ($0 verify fee + $0.0077 SMS base + a mandatory ~$0.0035 to $0.0050 carrier surcharge), vs Prelude's ~$0.035. So ~3x cheaper all-in, NOT the ~7x the headline $0.0077 (or Plivo's own $530-per-100k figure) implies; both of those EXCLUDE carrier surcharges, so do not budget against them. Pure pay-as-you-go, $10 free signup credit, no published platform fee.
- Maturity / longevity: founded 2011 (YC S12), bootstrapped, profitable since ~2015, ~$86.6M revenue (2024), ~70k customers, only ~$2M VC ever raised (none since 2012). The textbook durable infrastructure vendor and the ONE axis where it clearly beats Prelude (the Series-A longevity worry that triggered this look).
- Compliance breadth (on paper): SOC 2 Type 2, ISO 27001:2022, PCI DSS L1, HIPAA BAA, GDPR/DPA, EU-US DPF (SOC 3 public; full SOC 2 under NDA). Caveat: Plivo's privacy policy DEFAULT permits sharing data with third-party advertisers (opt-out) and enriching from data brokers, a weaker default than Prelude's stated "never sells/shares."
- Integration fit: the create-session -> validate flow returns only session/phone/status with NO app-side UID field, so the mint-our-own-token, provider-never-learns-the-UID property holds exactly as with Prelude (CONFIRMED safe). Mature official Node SDK with Verify-session support and TS types. Verify is documented as CARVED OUT from Plivo's ~$1,000/mo SMS-API Minimum Monthly Commitment (self-serve, "no spend commitments"), but the whole cost case rests on that single carve-out, so confirm it (below).
Where Prelude wins (and why it matters for this app)
- Billing model / AIT (the decisive one): Plivo is NOT success-only. It bills per SENT message (including undelivered); only invalid-number sends and pre-handoff-blocked sends are free. So an OTP-pumping / AIT attack costs us per send, bounded ONLY by Fraud Shield + Geo Permissions + per-country rate caps actively blocking, NOT by a structural cap. Prelude's success-only billing pins that cost near $0 no matter how good detection is. This re-exposes the exact risk 11.6 exists to contain. Worse, Plivo's explicit "not charged" language covers Geo Permissions (error 403) and Fraud Thresholds (error 451) but NOT Fraud Shield (error 452, the control the cost cap most depends on), so the cap is IMPLIED, not stated, and Fraud Shield's leakage rate at the default Medium level is unpublished.
- Jurisdiction + retention: Plivo has NO EU at-rest residency for Verify/SMS logs; EU data is transferred to US AWS under DPF + SCCs (a legally fragile basis). Default retention is 7 years of redacted CDR/MDR (only the last 3 phone digits masked) plus 7 years post-account-closure. That erases the EU differentiator that put Prelude ahead and conflicts with the data-minimization / lose-don't-leak axiom (11.0).
- Deliverability + 10DLC burden: switching takes on the full US A2P 10DLC registration ourselves: ~1 to 2 week vetting lead time (a launch-timing dependency), throttled throughput until vetted, and a recurring T-Mobile $250-per-campaign non-use fee on every 60-day idle window with NO OTP/low-volume exemption (a silent trap for a quiet pre-launch app, requiring deliberate ~59-day keep-alive sends). Prelude abstracts all of this behind a pre-registered sender. Plivo's 99.99%/99%+ figures are marketing, not a contractual SLA.
- CI test path: Plivo has NO free non-sending test number; its only sandbox path sends a real billed SMS. The Stage-A "test numbers only" guardrail (11.7) has no Plivo analog, so CI would mock at the adapter boundary instead. (SDK footgun: a caller-supplied custom
otpworks in plivo-python but is SILENTLY DROPPED by plivo-node, and still sends/bills an SMS.)
Pre-commit questions for Plivo (send to sales / security before any switch; on top of the Prelude list above):
- 452 not billed (in writing): confirm Fraud-Shield-blocked sends (error 452) are NEVER billed, matching the explicit "not charged" language Plivo already publishes for Geo Permissions (403) and Fraud Thresholds (451). This is the load-bearing assumption of the AIT cost cap.
- Per-send vs success: confirm we are billed for every OTP SMS handed to a carrier (incl. undelivered), and whether ANY success-only / AIT cost-cap option exists on Verify.
- Fraud Shield efficacy: any committed block-rate / false-negative figure at the default Medium level, and what residual pumped-send leakage to budget for on US 10DLC.
- Verify MMC carve-out: confirm a pre-launch (zero-traffic) app can onboard to Verify self-serve with NO Minimum Monthly Commitment and no sales gate.
- EU residency: confirm whether ANY EU at-rest residency exists for Verify CDR/MDR + OTP content, or state plainly that all EU data is US-stored.
- Retention shortening: can the 7-year warehouse + 7-year post-closure retention be contractually shortened in a DPA, what is the user-deletion purge SLA, and is log=false / redaction available on Verify specifically (suppressing the OTP value end-to-end).
- CI test number: any deterministic fixed-code test number that returns a known OTP WITHOUT sending or billing an SMS.
- Contractual SLA: the actual SLA doc (binding uptime %, deliverability commitment, service credits); is there a Verify-specific deliverability SLA comparable to 99.5%.
- Ad-sharing / brokers: contractual DPA commitment NOT to share our or end-user data with advertisers and NOT to enrich from data brokers (their default policy permits both).
- 10DLC specifics: exact low-volume OTP campaign fees (brand reg, campaign vetting, monthly), the realistic vetting lead time, and any exemption to the T-Mobile $250 non-use fee.
Genuine unknowns the public material did not settle: Fraud Shield's leakage rate; whether 452 blocks are truly never billed; whether the Verify MMC exemption holds for a pre-launch app; whether the 7-year retention is contractually shortenable. (Re-verify all pricing at purchase.)
11.5 Honest ceiling and residuals (corrects the ยง9 "no path from phone to userId" claim) โ
The end-state seals the past: stored records cannot be resolved phone-to-UID without the user's PIN, and the bulk/retroactive mapping is discarded. It does not achieve "never." Remaining residuals:
- Firebase Auth UserRecord (11.1), until the signup bootstrap is replaced AND existing records are scrubbed.
- In-use login moment: the server transiently holds phone + UID at each login (Confidential Space only partially mitigates).
- Prospective targeted compulsion: a court can compel capture of a NAMED target at their next login, going forward. This is universal to any live-infrastructure operator, Signal included; it is not closable by encryption.
- Third parties: the OTP provider (sees the phone) and the EKM key partner (holds the key) are each compellable in their own jurisdiction.
- Existence oracle: "does an account exist for phone X?" remains answerable via the hash lookup; only "which UID / what data" is sealed.
- Admin/staff shared-UID re-link (follow-up, not yet fixed): a staff member who is also a consumer user keeps ONE Firebase UID for both roles, because admin promotion reuses the existing user's UID (
adminUsers.js). Their contact phone inadminProfiles/{uid}is reversibly encrypted under a server-held key (decrypted on every read viaGET /auth/admin/profile/phone), so the operator can recover the phone-to-UID pair for that person, and that UID is their sealed consumer identity, so the Stage B seal is bypassed for them. Scoped to admins who are also app users; the admin contactphoneHashis domain-separated (admin-contact:v1:) so it does NOT cross-join the consumerphoneHashindex (that specific correlation is blocked by design). Fix = admin-auth decoupling: mint a fresh random admin UID instead of reusing the consumer UID, and link admin to user by phone/email hash rather than a shared UID. Resolves the never-answered open question #2 indocs/plans/2026-03-20_admin-auth-provider-linking_plan.md. Confirmed by a 2026-06-29 adversarial audit (severity HIGH). Also re-checkmerchantProfiles.phonefor the same recoverable-phone pattern.
Claim language (for privacy policy / marketing / counsel):
- Accurate: "We cannot resolve a stored phone number to an account, and we have discarded the ability to do so in bulk or retroactively; a subpoena of our records yields a locked blob."
- NOT accurate: "It is impossible for us to ever determine this for a targeted user" (false for any operator running live infrastructure).
11.6 Cost control: bracketing OTP spend โ
OTP cost is purely volume-driven. Actual lantern-app-dev billing (Jan-Jun 2026) shows ZERO Identity Platform / auth spend (no signup volume yet); the bill is ~$3/month, dominated by Artifact Registry, App Engine, and Secret Manager. The cost only appears with real signups, and it appears the same whether the SMS is sent by Firebase or by an external provider (carriers charge for the text either way). Only signups + new-device verifications cost money; returning logins (custom-token + PIN) are free.
To make spend predictable, bracket it in TWO layers:
- App-level monthly cap (primary, graceful): an atomic monthly counter (a
metrics/otpSends/{YYYY-MM}doc incremented viaFieldValue.increment, mirroring the existingfailedLoginAttemptspattern atadminAuth.js:86) gates OTP SENDS. Past the cap, refuse with a graceful "onboarding in batches / waitlist" response, not a hard error. - Provider / billing hard cap (backstop): a hard spend ceiling at the OTP provider (Twilio usage trigger or prepaid balance) or a GCP billing budget with auto-pause. It fails ugly (sends start erroring), so it is the backstop, not the primary; it guarantees the ceiling even if Layer 1 is bypassed by a bug or an attack.
Cap the cost driver (sends), not just completed signups. Counting only finished accounts lets abandoned or fraudulent sends (each an SMS) drain the budget while the signup counter reads low. Keep the fraud controls underneath both layers (App Check + per-IP and per-number rate limits via the existing rateLimiter.js + the provider Fraud Guard), or a pumping attack simply consumes the monthly cap in an hour and locks out real users.
Do not gate the free paths: returning logins cost nothing (no cap), and new-device verification for EXISTING users should be rate-limited but NOT hard-capped (or a real user with a new phone is locked out when the bucket is full). Target the cap at NEW signups.
Where it plugs in: today the verification SMS is sent CLIENT-SIDE by Firebase signInWithPhoneNumber before the server is involved, so there is no server-side choke point to gate the send (interim levers: Firebase SMS region policy + a GCP billing budget). In the target custom-token architecture (11.1), the OTP send moves SERVER-SIDE to a new endpoint that calls the external provider (e.g. POST /auth/phone/send-otp). That endpoint becomes the single money-spending choke point: App Check + rate limit + the monthly send-counter gate all sit there, BEFORE the provider call. The account mint at phoneCreateUser.js (POST /auth/phone/createUser) stays as the post-verification step (no SMS cost).
Bracket sizing (divide the monthly dollar ceiling by per-verification cost): a 500 / 1,000 / 2,000 signup cap is roughly $29 / $58 / $117 per month on Twilio, or $4 / $8 / $16 on Plivo.
Bonus: a monthly signup cap doubles as a paced / exclusive-rollout lever (invite waves, bounded moderation + support load, scarcity-driven demand), which fits a meet-strangers product and a finish-end-to-end-before-scaling posture. The tradeoff is turning away demand past the cap, so make the waitlist UX feel intentional.
11.7 Implementation status and flip checklist (2026-06-22) โ
The custom-token OTP bootstrap (11.1) is BUILT, in two slices, behind the OTP_PROVIDER flag (default firebase, so it is DORMANT until a deliberate per-environment flip). Provider: Prelude (@prelude.so/sdk); Twilio Verify is a documented config-swap fallback stub, so the vendor is a config value, not a rewrite.
Where this sits in the staging (read this first - the Stage A/B labels are confusing) โ
The Stage A / Stage B labels in sections 4 and 11 hide a THIRD axis. There are three independent places a phone is (or was) resolvable, and they advance separately. This work is NOT "Stage B"; it is the custom-token bootstrap (11.1), which 11.0 folded into the "if we custom-token, go all the way" commitment.
| Axis | What it seals | Status |
|---|---|---|
| Stage A | Firestore users.phone -> phoneHash (HMAC + KMS pepper); no plaintext phone in the user doc | DONE (predates this work) |
| Custom-token bootstrap (11.1) | The Firebase AUTH record phone credential: external OTP + an app-controlled RANDOM UID, so the phone never touches the Auth record | DONE here (Slice 1 + 2), DORMANT behind the flag |
| Stage B (proper) | The phoneHash -> userId resolution itself, encrypted under the user's PIN (encryptedUserIdBlob), so even the operator cannot resolve it at rest | BUILT here (slices 0-6), DORMANT behind STAGE_B_SEALED_USERID_ENABLED (see "Stage B build" below) |
After this work, with STAGE_B_SEALED_USERID_ENABLED OFF the server still resolves phoneHash -> userId (verify-otp and createUser query users.where(phoneHash)); with it ON, that resolution is sealed under the PIN (auth_lookup/{phoneHash} holds an encryptedUserIdBlob ciphertext, no plaintext userId). So the honest one-liner: Stage A + the custom-token bootstrap are done and dormant; Stage B is now built and dormant behind its own flag, ready for a dev-first flip.
Stage B build (the sealing itself) - slices 0-6, BUILT + DORMANT behind STAGE_B_SEALED_USERID_ENABLED โ
Built on claude/sealed-identity-stage-b, default OFF (byte-for-byte today's behavior until flipped). Server flag STAGE_B_SEALED_USERID_ENABLED, client flag VITE_STAGE_B_SEALED_USERID. The auth_lookup/{phoneHash} collection is server-only (firestore.rules deny). Blob key is HKDF(entropy), phone-independent.
- Slice 0 -
auth_lookupdeny rule; flags;authLookup.service.js; client blob crypto (deriveUserIdBlobKey/encryptUserIdBlob/decryptUserIdBlob). - Slice 1 - new signup writes the sealed row + omits
users.phoneHash(proof/bootstrap path); uniqueness via point-read + legacy fallback. - Slice 2 -
/lookupdual-read (sealed point-read first, legacy fallback), sealed response withoutuserId; client decrypts the blob to recover the userId. - Slice 3 - lazy migration
POST /auth/phone/seal(write sealed row + drop legacyphoneHashatomically in one transaction, binding-checked, idempotent). - Slice 4 -
verify-otpstops silent UID reuse: a returning account returnsmode:'recover'(PIN-gated), never a minted session; durable per-phoneHash recover-attempt cap. - Slice 5 - recovery-phrase seed rewrite moves server-side (
POST /auth/phone/rewrap-seed) to keep the duplicatedencryptedSeedinauth_lookupin sync; proof-of-entropy binds the rewrite to the right account. - Slice 6 - start-over (lost PIN + no phrase): ban re-check, sever the phone link (
auth_lookup/ legacy users doc + Auth record), fresh random UID.
Known limitations / follow-ups (for review):
- Sealed-row reclaim is scoped OUT (phoneRecycling): legacy-row reclaim is unaffected; sealed-row reclaim is documented as existence + grace only (the server cannot resolve a sealed userId). Optionally mirror
lastActiveAtintoauth_lookuplater. - Start-over deep cascade: the auth-api severs identity but cannot trigger the userId-keyed cascade (
cascadeDeleteUserDatais a CF-only helper; sealed accounts hide the userId). The old userId-keyed data is orphaned and inert (lose-don't-leak), not actively cascaded. - Client start-over UI (re-verify OTP with
startOver:true) is a follow-up; recover-to-login already covers PIN/phrase users. - openapi.json for the new auth endpoints is deferred (auth's spec is non-enforced + already drifted; the linter cannot parse auth's inline-router dispatch).
phoneReclaimsresidual link: the phone-recycling reclaim flow writes a doc holdingphoneHashtogether withrequestedBy: <uid>, so for reclaim participants aphoneHash -> uidassociation persists at rest (a narrow dedup/audit record the sealedauth_lookupdoes not have). Surfaced by the ยง14.6 leakage check; revisit if/when reclaim itself is sealed. Admin/staff accounts are likewise out of scope (never sealed; the admin portal links by phone by design). The data-leakage verification checklist lives in the Stage B plan ยง14.6.
Login ban gate (BUILT). A banned phone can no longer LOG IN, sealed accounts included. The login lookup (/auth/phone/lookup) now runs isPhoneBanned(phoneHash) before returning the blob/userId, and /auth/phone/token re-checks it for legacy docs (defense in depth). Because the client supplies the phone, the gate refuses by phoneHash WITHOUT resolving the userId, so it works for sealed accounts and never unseals anything (mirrors the signup gates). This is the phone-side answer to the ban half of #621. Residuals: (a) a CURRENTLY-live session expires within ~1h (revoking it needs the userId, which sealing hides from the phone); (b) a sealed user who scripts /token with their own known userId bypasses the gate (no phoneHash on a sealed doc to re-check) - closing that needs the userId, i.e. the moderation-by-userId path in #621. Update (2026-06-28): /token now also gates on the two userId-keyed signals a ban leaves behind (users.banned + Firebase Auth disabled), both of which survive sealing, so a sealed account banned by userId is refused at /token too (Case 1, closed). Only a sealed account banned by number alone stays open (Case 2), bounded exactly as in (a). Full picture with diagrams: SEALED_ACCOUNT_BAN_FLOW.md. Fail-open if PHONE_HASH_PEPPER is unset (a config gap must not lock everyone out of login). The dev helper npm run otp:test:ban exercises this end to end.
Slice 8 - observability (BUILT). Structured req.log events feed the rollout: stageb.seal {outcome} (migration progress), stageb.recover_divert {sealed}, stageb.start_over {authLookupDeleted, legacyDocs} (warn-level audit; start-over delete failures are now logged, not swallowed), stageb.rewrap_seed {sealed}. They flow to Cloud Run logs alongside the central errorHandler's 500-with-stack.
Dev test runbook + test log. The hands-on procedure (flag/deploy prerequisites, the otp-local-test reset/purge loop, the case grid mapped to ยง9, and the stageb.* events to watch) plus a per-session test log live in the Stage B plan, ยง14-15. For the dev test loop: npm run otp:test:reset now clears the sealed auth_lookup row (else the next signup diverts to recovery), and npm run otp:test:purge-orphans cleans the inert sealed orphans that accumulate across cycles, via the invite ledger (createdBy:'otp-local-test' -> usedBy:<uid>, a paper trail with no phoneHash->uid map).
What shipped / is built โ
Slice 1 (shipped to dev, DORMANT) - PR #608, merged. The money-spending SEND choke point: POST /auth/phone/send-otp with App Check + durable per-IP + durable per-phone + the monthly send-cap from 11.6, all before the provider call; a thin provider adapter (otpProvider.service.js) with a closed, contract-tested errorCode set; and the send counter at a FLAT metrics_otpSends/{YYYY-MM} collection (the literal metrics/otpSends/{YYYY-MM} above is an invalid odd-segment doc path; this is the correction). The dev auth-api mounts PRELUDE_API_KEY but keeps OTP_PROVIDER=firebase, so nothing changed for users.
Slice 2 (built + locally validated; branch claude/otp-provider-slice-2, not yet merged). The verify + bootstrap mint:
POST /auth/phone/verify-otp: confirm the code with the provider, computephoneHashwith the pepper, BAN-CHECK before minting, resolve the UID (reuse an existing NON-staff account by phoneHash, else a fresh app-controlled RANDOM UID), write a single-useotpVerifications/{markerId}marker, and return a Firebase custom token + an HMAC-signed proof (bound to phone + uid + markerId, 10-minute expiry).phoneCreateUsergains an optionalproofpath, branching on proof PRESENCE so the legacy Firebase path stays byte-for-byte and admin/no-proof requests work on any env. The proof is verified offline, then ONE Firestore transaction consumes the marker, enforces phoneHash uniqueness (the legacy path got this free from Firebase phone-credential uniqueness; the proof path must enforce it), re-checks idempotency, and writes the doc.- Client (
PhonePinSignup, behindVITE_OTP_PROVIDER): the non-admin signup uses send-otp / verify-otp / the proof; admin stays on Firebase phone-link. The flag-off path is unchanged.
Privacy property, validated end-to-end (local: dev Firestore + Prelude test number, no real SMS) โ
A signup through the external path creates a Firebase Auth UserRecord with NO phoneNumber and no provider data: the phone never touches the Auth record. This closes 11.5 residual #1 FOR NEW external-OTP signups (it does NOT retroactively scrub existing Firebase-phone records; see deferred). Verified on a clean run: the users doc carries phoneHash and no plaintext phone; the marker is consumed single-use; one verify maps to one UID with no orphan; signup lands on the dashboard.
Hardening applied (adversarial review) โ
Ban-check and refuse-role-account-reuse in verify-otp (a banned or recycled number must not receive a session, and a staff/admin account is never bootstrapped through the consumer OTP path); require PHONE_HASH_PEPPER so verify-otp's lookup key matches createUser's uniqueness key; a client re-entry guard so a double submit cannot mint a second UID + marker.
Ban enforcement: verified (phone-level banning survives Stage B) โ
Phone-level banning was the whole point of the custom-token move, so it was put through an adversarial review (skeptics tasked with getting a banned phone through). Verdict: it holds, and it survives Stage B.
Why it survives Stage B. A ban is a membership check, not an identity lookup. isPhoneBanned (bannedAccounts.service.js) recomputes HMAC(pepper, incoming_phone) and asks the standalone banned_accounts collection one question: "is this hash on the list?" It never resolves phoneHash -> userId and never reads the users collection. Stage B seals the phoneHash -> userId resolution; the ban check does not use that resolution, so the two are structurally independent. (Two independent reviewers tried to argue Stage B breaks banning; both failed.) The ยง4 trade-off table's "Stage B: banned_accounts enforcement unaffected (independent index)" is exactly right. Put simply: shredding the "whose phone is this?" book (Stage B) does nothing to the bouncer re-fingerprinting each phone at the door and matching it against the banned stack (the ban check).
Confirmed no-bypass (all bypassable: false): re-registering with a fresh random UID after deletion (the ban keys on the phone hash, not the UID; a permanent ban has expiresAt: null forever); a ban landing inside the 10-minute proof window (createUser does an unconditional live ban re-check before any write, on BOTH the proof and legacy paths, and the proof carries no ban state); a missing pepper (both paths fail closed with 503); the verify-otp TOCTOU race (createUser's second check catches it). The new external path is in fact STRONGER than the legacy Firebase path: verify-otp ban-checks BEFORE minting the token, so a banned phone gets no session at all.
Residuals (neither defeats phone-banning; logged for follow-up):
- Legacy-path bare session (medium, pre-existing). On
OTP_PROVIDER=firebase, Firebase mints the session CLIENT-SIDE before the server runs, so a banned phone can hold a logged-in token but CANNOT create or use an account (createUserblocks 403 BANNED: no user doc, no role, no profile). Closed by flipping to the external path, or by a FirebasebeforeSignInblocking function. - Returning-login endpoint not ban-checked (low).
POST /auth/phone/token(phone.js) has noisPhoneBannedgate (it already imports the function). A phone-hash-banned user whose existing account was not ALSO disabled at the userId level could log back into their own account if they know the PIN. One-line fix.
Product question (not a bug): warning and shadow severities currently BLOCK at signup identically to permanent (the gate is purely active-vs-expired; severity is recorded but not consulted). If shadow is meant to be silent-but-allowed, that is not implemented today.
Flip checklist (ALL must be true before OTP_PROVIDER=prelude on an env) โ
STAGE_A_PHONE_HASH_LOOKUP_ENABLED=true+PHONE_HASH_PEPPERmounted, so verify-otp and createUser key on the samephoneHash.OTP_PROOF_SECRETprovisioned per-env (distinct local / dev / prod; it is a verification-bypass SIGNING key, not a hash pepper). Staged on dev.- A Firestore TTL policy on
otpVerifications.expireAt(reap consumed / abandoned markers), mirroring_rateLimits.expireAt. - The Layer-2 provider / GCP billing hard ceiling configured (11.6); confirm Prelude's
check-call cost is acceptable (verify-otp is guess-rate-limited but not dollar-capped). - Upgrade App Check on send-otp / verify-otp to limited-use / consume tokens once the client requests them.
- The three vendor pre-commit checks for REAL numbers (11.4): DPA (retention + subprocessors + EU-hosting), a real US deliverability test, and the vendor-longevity judgment. (Test numbers need none of these.)
Deferred (Stage B continuation, NOT in this work) โ
Removing signInWithPhoneNumber outright and scrubbing phoneNumber from EXISTING Firebase Auth records (11.1 / 11.5 residual #1 for legacy users); the Phase-5 plaintext-phone backfill; PIN-sealing the phone-to-UID blob (Stage B proper). Minor UX: a returning user who re-runs signup while already signed in churns the auth state (the existing-UID reuse path); smooth later.
Refs: full plan, 33-case test matrix, and red-team ledger in docs/planning/plans/2026-06-22-otp-provider-prelude-slice.md.