Authentication

Every request to GOLD PRO resolves to a principal — the account acting, and the tenant it belongs to. Browsers present a cookie; native apps present a Bearer token. Both are the same JWT, signed with the same secret, and both are resolved by the same helper, so a route never has to care which kind of client called it.

This page covers how to obtain a credential, how to send it, and what the server does with it before your handler runs.

The four principals

There are four roles, grouped into three kinds:

RoleKindTenant-scopedSigns in at
TENANT_ADMINstaffYes/login
TENANT_EMPLOYEEstaffYes/login
CUSTOMERcustomerYes<slug>.goldpro.shop
SUPER_ADMINsuperadminNo/system-gate

The union in src/lib/mobile-auth.ts is the authoritative shape:

export type Principal =
  | { kind: "staff"; id: string; role: "TENANT_ADMIN" | "TENANT_EMPLOYEE"; tenantId: string; name?: string; email?: string }
  | { kind: "customer"; id: string; role: "CUSTOMER"; tenantId: string; phone?: string }
  | { kind: "superadmin"; id: string; role: "SUPER_ADMIN"; email?: string };

The absence of tenantId on superadmin is the point: a super admin is not scoped to a shop, and the type makes it impossible to read one off them by accident. Staff and customers always carry one, and a staff account without a tenant cannot authenticate at all.

Routes narrow principals with four guards — isStaff, isTenantAdmin, isCustomer, isSuperAdmin. They are throw-free predicates; each caller returns whichever status it prefers.

Which credential to use

Four credentials exist. All are JWTs signed with NEXTAUTH_SECRET, which is why one decode() validates any of them regardless of which login minted it.

CredentialCarried asPrincipalLifetime
NextAuth sessionSession cookiestaffNextAuth default (30 days)
customer_tokenCookie, SameSite=Laxcustomer7 days
sa_tokenCookie, SameSite=Strictsuperadmin1 day
Native tokenAuthorization: BearerAny30 days; 1 day for SUPER_ADMIN

The super-admin Bearer deliberately mirrors the short sa_token lifetime rather than the 30-day default: it is the highest-privilege credential on the platform, so a stolen one expires in a day instead of a month. It is revocable through the device-session mechanism below, but the short lifetime remains a cheap second line of defence.

NEXTAUTH_SECRET signs all four. Rotating it invalidates every cookie and every outstanding Bearer token at once. If it is unset, authentication fails closed — Bearer resolution returns no principal, and protected /api routes answer 500, never an open door.

Signing in

Staff

The web signs in through the NextAuth credentials provider at /login, which sets the session cookie. Native clients get a Bearer token from a dedicated route:

curl -X POST https://goldpro.shop/api/mobile/auth/staff/login \
  -H "Content-Type: application/json" \
  -d '{"email":"asha@example.com","password":"$PASSWORD"}'
NameTypeRequiredDescription
emailstringYesStaff email address. Compared case-insensitively
passwordstringYesPlaintext password, verified against a bcrypt hash
{
  "token": "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..",
  "user": {
    "id": "clx3k9v2p0001qw8h5t7n2m4c",
    "name": "Asha Rao",
    "email": "asha@example.com",
    "role": "TENANT_ADMIN",
    "tenantId": "clx3k8f1a0000qw8h3r5b9k2d"
  }
}

Both paths share one credential check, so failure lockout, audit logging, and the tenant-active rule behave identically whether you sign in from a browser or the app. A SUPER_ADMIN is rejected here and must use the super-admin route.

Five failed attempts on one email lock it for 15 minutes; a success clears the counter. See Errors for the limits.

Customers

Customer sign-in is email OTP, in two steps. OTP is email-only across the platform — there is no mobile-number OTP.

curl -X POST https://goldpro.shop/api/tenant-portal/otp/send \
  -H "Content-Type: application/json" \
  -d '{"tenant":"sai-suppliers","email":"ravi@example.com"}'
{
  "success": true,
  "message": "If this email is registered, a verification code has been sent."
}

That response is identical whether or not the email is registered, and the code is delivered to the customer's stored address rather than the one submitted. Both properties are deliberate: neither the body nor the timing reveals who has an account.

Then exchange the code. The web route sets the customer_token cookie; the mobile route returns a Bearer token.

curl -X POST https://goldpro.shop/api/mobile/auth/customer/login \
  -H "Content-Type: application/json" \
  -d '{"tenant":"sai-suppliers","email":"ravi@example.com","code":"418302"}'
NameTypeRequiredDescription
tenantstringYesTenant slug. The subdomain the portal is served from
emailstringYesRegistered customer email, normalized before lookup
codestringYesThe 6-digit code. Any other length is rejected outright
{
  "token": "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..",
  "customer": {
    "id": "clx4m2p9q0003qw8h1v6r8t5e",
    "phone": "9876543210",
    "tenantId": "clx3k8f1a0000qw8h3r5b9k2d",
    "tenantSlug": "sai-suppliers",
    "tenantName": "Sai Suppliers"
  }
}

A customer whose status is Inactive cannot sign in, and neither can any customer of a deactivated tenant. Every other lifecycle stage — including Overdue and Closed — keeps portal access.

Super admin

POST /api/system-gate/login sets the sa_token cookie; POST /api/mobile/auth/superadmin/login returns the equivalent Bearer. Both take email and password, and both always run a bcrypt comparison — against a dummy hash when no matching super admin exists — so response timing does not disclose whether an account is there.

Calling with a Bearer token

Send the token on any route. The scheme is matched case-insensitively.

curl https://goldpro.shop/api/customers \
  -H "Authorization: Bearer $TOKEN"

Native clients validate a stored token on launch before showing the app:

curl https://goldpro.shop/api/mobile/auth/me \
  -H "Authorization: Bearer $TOKEN"
{
  "principal": {
    "kind": "staff",
    "id": "clx3k9v2p0001qw8h5t7n2m4c",
    "role": "TENANT_ADMIN",
    "tenantId": "clx3k8f1a0000qw8h3r5b9k2d",
    "name": "Asha Rao",
    "email": "asha@example.com"
  }
}

Store the token in the platform keystore — the apps use expo-secure-store — and re-authenticate on a 401.

How a request is authorized

Two layers run, and they check different things.

1. The edge pre-filter. The proxy protects /api by default: any route not on the self-authenticating allowlist must carry a valid session token or Bearer, or it never reaches a handler. Rejections are 401 JSON rather than a redirect, so fetch clients and the offline sync queue see a failure instead of following a redirect into an HTML login page.

These prefixes authenticate themselves in-handler and are allowlisted through:

PrefixAuthenticates with
/api/authNextAuth itself
/api/system-gateSuper-admin login; issues sa_token
/api/super-adminsa_token or super-admin Bearer, re-validated in-handler
/api/tenant-portalCustomer OTP login and customer_token
/api/customer-intakeSigned intake token
/api/otpIntake token / OTP verification flow
/api/cronCRON_SECRET Bearer
/api/contactPublic access-request form
/api/mobileNative auth and device registration; Bearer re-validated in-handler
/api/razorpayGateway webhook; HMAC over the raw body

Anything outside that list takes the default protection — including /api/onboarding/*, which is newer than the list and deliberately not on it.

2. The handler. Routes call getAuthContext(req), which resolves the real principal against the database.

The edge check is a pre-filter, nothing more. It proves the JWT decodes under NEXTAUTH_SECRET and carries one of the four known roles — it does not touch the database. A token belonging to a deactivated user, a suspended tenant, a demoted super admin, or an evicted device passes the edge and is rejected by the handler. Never treat "reached the handler" as authorization.

getAuthContext tries credentials in a fixed order:

  1. Authorization: Bearer — checked against the MOBILE session slot
  2. NextAuth staff session — WEB slot
  3. customer_token cookie — WEB slot
  4. sa_token cookie — WEB slot

Bearer is tried first so a user signed in on both a browser and the app keeps both. Every path performs the same re-validation: the account still exists, is active, belongs to an active tenant, still holds the role its token claims, and — for customers — is not Inactive. Only then is the session slot checked.

One browser, one app

Auth is stateless JWT, so a token cannot be revoked on its own; it stays valid until it expires. One piece of server state fixes that. A DeviceSession row per (principalType, ownerId, deviceClass) holds the sid of the only token of that class currently accepted.

Logging in upserts the row with a fresh sid, and that upsert is the eviction. The previous device's token still verifies against NEXTAUTH_SECRET, but its sid no longer matches the row, so the check fails on its next request.

deviceClass is WEB or MOBILE, and they are separate rows. A browser and the app can be signed in at the same time; a second browser evicts only the first browser.

The claim is called sid, never jti. NextAuth's encode() stamps its own random jti on every call, so a jti set here would be silently overwritten and no token would ever match its row. This is the kind of detail that fails silently and totally, so it is worth knowing if you touch token minting.

The check fails closed on a missing or blank sid: tokens minted before the feature shipped carry none, and honouring them would let every outstanding token bypass the limit for the rest of its 30-day life. lastSeenAt is refreshed at most once every five minutes and is bookkeeping only — it never participates in the auth decision.

The superseded outcome

Failures are not all alike, so the outcome type distinguishes them:

export type AuthOutcome =
  | { principal: Principal; superseded: false }
  | { principal: null; superseded: boolean };

superseded: true means the token verified and its principal is still perfectly valid, but a newer device of the same class took over. Account validity is checked before this is reported — otherwise a disabled user would be told they had signed in elsewhere.

Native clients turn it into a specific message. GET /api/mobile/auth/me answers 401:

{
  "error": "Signed in on another device.",
  "code": "SESSION_SUPERSEDED"
}

After any 401, a client can ask why. GET /api/mobile/auth/session-status always answers 200 — a 401 there would re-enter the client's own 401 handler and loop — with one of three values:

{ "status": "ACTIVE" }

ACTIVE, SESSION_SUPERSEDED, or INVALID. It discloses nothing a caller does not already hold, since you must present the token to learn anything about it.

On the web the equivalent is a redirect to /login?reason=superseded, so the login page can explain rather than showing a bare session-expired notice.

Signing out

ClientRouteEffect
Staff (web)NextAuth sign-outReleases the WEB slot, writes an EMPLOYEE_LOGOUT audit entry
Any (native)POST /api/mobile/auth/logoutReleases the MOBILE slot
Customer (web)POST /api/tenant-portal/logoutClears customer_token
Super admin (web)POST /api/system-gate/logoutClears sa_token

Sign-out revokes server-side rather than waiting for expiry. Every path is scoped to the caller's own sid, so a stale tab signing out cannot delete the row a newer device claimed — which would otherwise let a forgotten tab log out the device that replaced it.

POST /api/mobile/auth/logout always returns 200:

{ "success": true }

It never returns 401. The client discards its token either way, so a failure here must not block a local sign-out.

Disabled and evicted accounts

DISABLED is a sentinel role, not a real one. The NextAuth jwt callback writes it on a token's next refresh when either of two things is true:

  • the account was deactivated, or its tenant was, or
  • another browser took the account's single WEB slot — which also sets superseded: true, so the login page can say which happened

Reusing one sentinel for both means every existing check rejects both. The edge rejects DISABLED, the NextAuth authorized callback rejects it, and getCurrentUser returns undefined for it — so every if (!user) guard already written keeps working without a special case.

A DISABLED token still decodes and still looks structurally valid. It is the role check, not the signature, that stops it.

Next: Errors for status codes, rate limits, and idempotent retries, or Valuations to start writing data.