Appearance
Authentication mechanics
The login, signup and forgot password pages describe what the user does and which endpoint each tap hits. This page describes what the server does — what proves identity, what gets written, what gets sent, and what state the account is in at each step.
Read the two together: those pages are the client contract, this one is the mechanism. Each of the three carries a player — press Play and the steps advance against a phone, naming the call each one makes and what the server does with it.
| Those pages answer | This page answers |
|---|---|
| Which screen calls which endpoint | Which middleware runs before the handler, and what it proves |
| What fields to send | What the server validates, normalises and stores |
| What comes back | What else happened — record created, OTP issued and delivered, token minted |
| Where the user goes next | What state the account is in, and what it can do in that state |
The moving parts
Everything in the mobile auth surface lives in the customer backend. Nothing is brand-configurable except the reCAPTCHA switch.
| Part | Where | Job |
|---|---|---|
noCorsMiddleware | app/v1/middleware/ | Mobile routes skip the origin check the web routes apply |
isValidMobileKey | app/v1/middleware/isValidMobileKey.js | Verifies x-auth-token, resolves the brand, populates req.tenant |
isCustomer | app/v1/middleware/isCustomer.js | Verifies authorization, populates req.meta |
customer_controller.js | app/v1/controller/ | The joi block — the real request contract |
| The customer service | app/v1/service/ | Lookup, create, credential check, OTP issue and consume, token minting |
| The notification sender | app/v1/helpers/ebClient.js, enum/templateTypes.js | Delivers the OTP |
| Loyalty service (v2) | LOYALTY_SERVICE_URL behind /api/v2/brand/mobile/loyalty/* | Separate profile, created by its own init call |
Two tokens, two different claims
Mobile auth is not one session — it is a brand credential the app ships with plus a customer session the user earns. They are verified by different middleware against different secrets, and they fail differently.
x-auth-token | authorization | |
|---|---|---|
| Carries | Brand mobile key JWT | Customer JWT |
| Verified against | BRAND_MOBILE_KEY | JSON_CUSTOMER |
| Required claims | both brandId and hubId, or 401 | userId (plus level_id) |
| Populates | req.tenant — { brandId, hubId, org_id, type, loyaltyBrandId, isMobileSource: true } | req.meta — { userId, level_id, … } |
| Proves | which brand's app is calling | which customer is calling |
| Present when | Every mobile request, signed in or not | Only after a successful login |
Two consequences that drive most of the flow design:
- Tenancy is never a request parameter.
brandIdandhubIdcome off the brand token, so the app cannot ask for another brand's data by changing a field. The handful of endpoints that accept ahubId/roomIdquery param are overrides used only when the token does not carry one. - The URL prefix means nothing.
/api/v1/mobile/…,/api/v1/user/mobile/…and/api/v1/brand/mobile/…are mount points, not auth tiers. Whether a route needs a customer is decided purely by whetherisCustomeris in its middleware list — which is why some authenticated endpoints sit under the guest-looking/api/v1/mobile/prefix.
What happens to a request
Three properties of this pipeline decide how the client has to be written:
- 401 is the only honest status code. It comes from the two middlewares and nothing else. Every failure past that point — bad password, unknown email, expired OTP, joi rejection — is returned by
handleCatchas HTTP 200 withsuccess: false. A client that branches on the status code will treat a wrong password as a successful login. - Joi is strict. Unknown keys are rejected, not ignored. Sending a field the schema does not list fails the whole request.
- Only the first validation error is returned. A form with three bad fields produces three round trips, one message at a time.
How a customer is identified
A customer is keyed by email or phone number, and both are normalised before anything is looked up or stored:
emailis lowercased, and an address containing+is rejected on every customer-identity endpoint.phoneNumberhas every non-digit stripped, andphone_prefixbecomes required the moment a phone number is present.
Because either identifier alone is enough to create an account, the same person can end up with two records — signing up with email on one device and phone on another. That is the reason the API carries an account merge flow at all: it is a repair mechanism for an identity model that allows duplicates, not a feature the UI has to offer.
Sign-up: one call creates the account, a second one proves it
The mechanism that surprises people is that there is no verification endpoint. register creates the record and issues the OTP; login with that OTP both authenticates and serves as proof of the address. Between the two calls the account exists and is unproven.
What is not part of sign-up:
- No loyalty profile. It is a separate system behind
POST /api/v2/brand/mobile/loyalty/init, called once after the account exists. Skip it and the loyalty reads return an empty or failing profile rather than a new one. - No terms acceptance.
registerhas no field for it, so nothing is recorded server-side. - No password confirmation. The schema takes one
password; matching two inputs is entirely the client's. - No username. The availability check requires a customer token, so a username can only be chosen after the account exists.
Login: one endpoint, two credential types
POST /api/v1/mobile/login needs one identifier (email, or phoneNumber with phone_prefix) and one credential (password or otp). Both paths return the same customer JWT — an OTP login is not a lesser session.
The branch that catches client teams is reCAPTCHA: recaptcha_token is required on login and send-otp only when the brand has recaptcha_required set. It is brand state, not a global, so the app has to know the brand's setting before it can build the form — there is no "reCAPTCHA required" response to react to.
send-otp is a separate endpoint from login. It only issues a code; it never returns a session. The sequence for an OTP login is always two calls.
OTP is one mechanism used six times
The same issue-deliver-consume pattern carries six different operations. Seeing them together explains why OTP behaviour has to be defined once, not per screen.
| Operation | Issued by | Consumed by |
|---|---|---|
| Passwordless login | POST /mobile/send-otp | POST /mobile/login with otp |
| Sign-up verification | POST /mobile/register | POST /mobile/login with otp |
| Password reset | POST /mobile/resetPassword | POST /mobile/resetPassword/confirm |
| Account merge | POST /mobile/user/merge-user/send-otp | POST /mobile/user/merge-user/validate-otp |
| Account deactivation | POST /mobile/user/deactivate | POST /mobile/user/deactivate/confirm |
| Product ownership transfer | POST /mobile/initiate/transfer/product/ownership | POST /mobile/transfer/product/ownership |
Two of these share an issuing surface with something else, which is where double-issue bugs come from: calling send-otp straight after register sends a second code for the same account, and calling resetPassword twice reissues. Whether the older code stays valid is not established — see the open items.
Password reset: a three-call chain that mints a real session token
Three mechanical points, none of them visible on the screen flow:
- The identifier is a joi
xor: sending bothemailandphoneNumberfails exactly as sending neither does. - The intermediate
tokenis a full customer JWT signed with the same config as a login token — the reason it must be used as the path parameter and then discarded is spelled out on the forgot password page. changePasswordends the chain without a session, so the "success" screen has to log in again. Whether the old password's existing sessions survive the change is not established.
The "email not registered" branch the UI draws is a server behaviour, not a client choice: the endpoint distinguishes the two cases in its response body, so the API leaks whether an address is registered. Changing that is a backend decision.
OAuth: "register" is also login
register/google-oauth and register/apple-oauth are named for the first-time case but serve returning users too — there is no separate sign-in endpoint and no "already registered" branch to handle. Google's call takes email plus oauth_access_token; Apple's accepts only oauth_access_token, so the app must not send the profile fields it can send to Google.
Both return the customer JWT directly — no OTP step, because the provider has already proved the address.
Account states
Deactivation has two doors: deactivate/direct deletes immediately with no confirmation, while deactivate issues an OTP that deactivate/confirm consumes. Both are customer-authenticated. Each also has an OAuth-flow alias mounted on the same handler — GET /user/mobile/deregister/oauth is deactivateUser, and PUT /user/mobile/deregister/apple-oauth/confirm is deactivateUserConfirmation — so the same behaviour is reachable by four paths.
Failure semantics
| Condition | Status | Body |
|---|---|---|
Missing / invalid x-auth-token | 401 | middleware error |
Brand token without both brandId and hubId | 401 | middleware error |
Missing / invalid authorization on a customer route | 401 | middleware error |
Missing payload wrapper | 200 | success: false, error.message: "payload is required" |
| Joi rejection | 200 | success: false, first failing message only, sometimes "INVALID PAYLOAD: Check Payload Arguments" |
| Unknown email on reset | 200 | success: false |
| Wrong password, wrong or expired OTP | 200 | success: false |
Enforced where
| Rule | Enforced by |
|---|---|
Email lowercased, + rejected | Server |
Phone digits stripped, phone_prefix required with a phone number | Server |
| Password minimum 8 characters | Server |
| One identifier and one credential on login | Server (joi) |
Exactly one identifier on reset (xor) | Server (joi) |
| reCAPTCHA, when the brand requires it | Server, from brand state |
| Password re-entry matches | Client only |
| Terms accepted | Client only — nothing stores it |
| Signup button disabled until the form is valid | Client only |
What this page cannot tell you
Everything above is traceable to the endpoint contracts and middleware. The following is the part a mechanism doc has to be honest about: it is not established by the API surface, and each item needs one look at the customer backend before anyone designs around it. Listed with where to look.
| Question | Where the answer is |
|---|---|
| How long is an OTP valid? | the customer service's OTP issue path |
| Is an OTP single-use, and does reissuing invalidate the previous code? | same |
| Is there an attempt limit or lockout on wrong OTPs and passwords? | same, plus loginUser |
| How long is the customer JWT valid, and is there any refresh? | token minting in the customer service |
| Are sessions invalidated when the password changes or the account deactivates? | changePassword, deactivateUser* |
| How are passwords hashed and compared? | loginUser / createCustomer |
Does register reject an email that already exists, or create a duplicate? | createCustomer |
| Is customer identity unique per brand or global? | the customer model and createCustomer |
| Is the OTP delivered by email, SMS, or by whichever identifier was sent? | ebClient.js + templateTypes.js |
| Are the provider tokens verified server-side against Google and Apple? | registerGoogleVerifiedCustomer, registerAppleVerifiedCustomer |
| Is there rate limiting on OTP issuing endpoints? | route middleware lists |
What exactly does register return — is userId in the response? | createCustomer |
Do not answer these from field names
A field existing in a schema says nothing about whether anything reads it. Each row above needs the code path that consumes the value, not the name of the column that stores it.