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:
| Role | Kind | Tenant-scoped | Signs in at |
|---|---|---|---|
TENANT_ADMIN | staff | Yes | /login |
TENANT_EMPLOYEE | staff | Yes | /login |
CUSTOMER | customer | Yes | <slug>.goldpro.shop |
SUPER_ADMIN | superadmin | No | /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.
| Credential | Carried as | Principal | Lifetime |
|---|---|---|---|
| NextAuth session | Session cookie | staff | NextAuth default (30 days) |
customer_token | Cookie, SameSite=Lax | customer | 7 days |
sa_token | Cookie, SameSite=Strict | superadmin | 1 day |
| Native token | Authorization: Bearer | Any | 30 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_SECRETsigns 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/apiroutes answer500, 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"}'
| Name | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Staff email address. Compared case-insensitively |
password | string | Yes | Plaintext 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"}'
| Name | Type | Required | Description |
|---|---|---|---|
tenant | string | Yes | Tenant slug. The subdomain the portal is served from |
email | string | Yes | Registered customer email, normalized before lookup |
code | string | Yes | The 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:
| Prefix | Authenticates with |
|---|---|
/api/auth | NextAuth itself |
/api/system-gate | Super-admin login; issues sa_token |
/api/super-admin | sa_token or super-admin Bearer, re-validated in-handler |
/api/tenant-portal | Customer OTP login and customer_token |
/api/customer-intake | Signed intake token |
/api/otp | Intake token / OTP verification flow |
/api/cron | CRON_SECRET Bearer |
/api/contact | Public access-request form |
/api/mobile | Native auth and device registration; Bearer re-validated in-handler |
/api/razorpay | Gateway 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_SECRETand 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:
Authorization: Bearer— checked against theMOBILEsession slot- NextAuth staff session —
WEBslot customer_tokencookie —WEBslotsa_tokencookie —WEBslot
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, neverjti. NextAuth'sencode()stamps its own randomjtion every call, so ajtiset 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
| Client | Route | Effect |
|---|---|---|
| Staff (web) | NextAuth sign-out | Releases the WEB slot, writes an EMPLOYEE_LOGOUT audit entry |
| Any (native) | POST /api/mobile/auth/logout | Releases the MOBILE slot |
| Customer (web) | POST /api/tenant-portal/logout | Clears customer_token |
| Super admin (web) | POST /api/system-gate/logout | Clears 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
WEBslot — which also setssuperseded: 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.