Skip to content

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 answerThis page answers
Which screen calls which endpointWhich middleware runs before the handler, and what it proves
What fields to sendWhat the server validates, normalises and stores
What comes backWhat else happened — record created, OTP issued and delivered, token minted
Where the user goes nextWhat 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.

PartWhereJob
noCorsMiddlewareapp/v1/middleware/Mobile routes skip the origin check the web routes apply
isValidMobileKeyapp/v1/middleware/isValidMobileKey.jsVerifies x-auth-token, resolves the brand, populates req.tenant
isCustomerapp/v1/middleware/isCustomer.jsVerifies authorization, populates req.meta
customer_controller.jsapp/v1/controller/The joi block — the real request contract
The customer serviceapp/v1/service/Lookup, create, credential check, OTP issue and consume, token minting
The notification senderapp/v1/helpers/ebClient.js, enum/templateTypes.jsDelivers 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-tokenauthorization
CarriesBrand mobile key JWTCustomer JWT
Verified againstBRAND_MOBILE_KEYJSON_CUSTOMER
Required claimsboth brandId and hubId, or 401userId (plus level_id)
Populatesreq.tenant — { brandId, hubId, org_id, type, loyaltyBrandId, isMobileSource: true }req.meta — { userId, level_id, … }
Proveswhich brand's app is callingwhich customer is calling
Present whenEvery mobile request, signed in or notOnly after a successful login

Two consequences that drive most of the flow design:

  • Tenancy is never a request parameter. brandId and hubId come off the brand token, so the app cannot ask for another brand's data by changing a field. The handful of endpoints that accept a hubId/roomId query 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 whether isCustomer is 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 handleCatch as HTTP 200 with success: 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:

  • email is lowercased, and an address containing + is rejected on every customer-identity endpoint.
  • phoneNumber has every non-digit stripped, and phone_prefix becomes 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. register has 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.

OperationIssued byConsumed by
Passwordless loginPOST /mobile/send-otpPOST /mobile/login with otp
Sign-up verificationPOST /mobile/registerPOST /mobile/login with otp
Password resetPOST /mobile/resetPasswordPOST /mobile/resetPassword/confirm
Account mergePOST /mobile/user/merge-user/send-otpPOST /mobile/user/merge-user/validate-otp
Account deactivationPOST /mobile/user/deactivatePOST /mobile/user/deactivate/confirm
Product ownership transferPOST /mobile/initiate/transfer/product/ownershipPOST /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 both email and phoneNumber fails exactly as sending neither does.
  • The intermediate token is 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.
  • changePassword ends 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 ​

ConditionStatusBody
Missing / invalid x-auth-token401middleware error
Brand token without both brandId and hubId401middleware error
Missing / invalid authorization on a customer route401middleware error
Missing payload wrapper200success: false, error.message: "payload is required"
Joi rejection200success: false, first failing message only, sometimes "INVALID PAYLOAD: Check Payload Arguments"
Unknown email on reset200success: false
Wrong password, wrong or expired OTP200success: false

Enforced where ​

RuleEnforced by
Email lowercased, + rejectedServer
Phone digits stripped, phone_prefix required with a phone numberServer
Password minimum 8 charactersServer
One identifier and one credential on loginServer (joi)
Exactly one identifier on reset (xor)Server (joi)
reCAPTCHA, when the brand requires itServer, from brand state
Password re-entry matchesClient only
Terms acceptedClient only — nothing stores it
Signup button disabled until the form is validClient 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.

QuestionWhere 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.

Batch platform, Shopify app and API documentation