# Customer Sign-in Hardening — Backend Delivery Note → Mobile Team

**From:** backend · **Date:** 2026-08-16 · **Status: ✅ LIVE** on `tailwind-poc`.
**Contract impact: one response-shape change on `POST /user/customer-sign-in` (details + why
we are confident it does not break your current build below). Everything else is a pure fix.**

---

## What was wrong

1. **`POST /user/customer-sign-in` returned a WORKING bearer token + the full profile
   (name, email, saved-card last-fours, pending bookings, home location) to anyone who
   posted a customer's phone number — before any OTP was seen.** Account takeover + PII
   disclosure by phone-number knowledge alone.
2. **Wrong-account resolution on shared mobile variants.** Two accounts can hold the same
   number in different shapes (`966509333989` vs `509333989`). The lookup picked whichever
   row came first — a real customer was refused with "no permission" because a shop-owner
   row matched their number's prefixed variant first.
3. **`firebase_token` was saved at sign-in (pre-OTP)** — an attacker posting a victim's
   number with their own device token would receive the victim's push notifications.

## What changed

| # | Endpoint | Change |
|---|---|---|
| 1 | `POST /user/customer-sign-in` | Response is now `{"token": null, "profile": null, "sms_response": …}` (keys kept, values null). **The working token comes from `POST /user/verify`, as it always effectively did** — verify ROTATES `access_token` on every successful OTP, so the copy sign-in used to return died seconds later anyway. No client flow can have depended on it surviving. |
| 2 | `customer-sign-in` / `verify` / `resend-otp` | Account resolution fixed: the email clause only exists when a non-empty `email` was sent; `customer-sign-in` only matches CUSTOMER accounts; `verify`/`resend-otp` (which agents also use) resolve variant collisions to the account holding the freshest pending OTP. Net effect for you: customers whose number collides with a salon account **can now actually log in**. |
| 3 | `customer-sign-in` | No longer persists `firebase_token`. **`/user/verify` persists it** (you already send it there). No app change needed. |

## 🔴 One QA check on your side

Run your login E2E once against staging: **sign-in → OTP → verify → use verify's token.**
If (and only if) some code path reads `token` from the *sign-in* response, it will now get
`null`. We could find no way such a path could ever have worked (rotation kills that token
at verify), but confirm.

**Emergency valve:** if anything breaks in the field, ops can set `AUTH_SIGNIN_TOKEN_COMPAT=1`
(server env) to restore the legacy sign-in body instantly while you patch — tell us and we
flip it. The flag is temporary and will be deleted after your confirmation.

## Follow-up sweep (2026-08-16, same day) — all sibling lookups migrated

The remaining endpoints that used the same fragile inline lookup are now on the shared
resolver too. **No response SHAPE changed anywhere**; the row-resolution got correct:

| Endpoint | What got fixed | Anything you'd notice |
|---|---|---|
| `POST /user/sing-up` | Duplicate check matched the mobile RAW while accounts store it as `966…`, so a re-signup in another shape (`05…`/`5…`/`+966…`) created a SECOND account. Now caught. | A signup that *should* have been blocked as a duplicate now correctly returns **"Already exist"** (400). Legit new signups are unaffected. |
| `POST /user/sign-in` | Mobile-variant collision resolved to the wrong account. Now scoped to the requested `user_type`. | In the rare wrong-type collision the error shifts from "no permission" → "Invalid Data" (both 400). Normal logins unchanged. |
| `POST /user/agent-sign-in` | Same, scoped to AGENT. | Same as above. |
| `POST /user/request-password-reset`, `verify-reset-code`, `reset-password` | Email-only; empty `email` no longer matches an empty-email row; resolution is now deterministic. | None (email is unique). |
| `ProfileController::actionVerify` (old password-via-OTP) | Email clause conditional + mobile matched via normalized variants. | None expected. |

Live-verified: a real customer/agent sharing one number in different shapes now each sign
in to the RIGHT account; password-reset with a real email sends, empty email is rejected,
unknown email 404s. Nothing else in the auth surface changed.
