Skip to content

Authentication API

User registration, login, OTP verification, session management, and profile management.

Credential Rules

RuleDescription
At least one requiredRegistration and login require at least one of phone or email
Phone formatSaudi E.164: +966XXXXXXXXX (12 digits, +966 prefix + 9 digits)
Email formatStandard RFC 5322 email
Dual credentialIf both phone and email are provided, OTP is sent via both channels
UniquenessPhone and email are each globally unique across all users
Nullabilityphone is null for email-only users; email is null for phone-only users

The Saudi phone format above applies to self-service registration (POST /auth/register) and phone changes. B2B roster-provisioned accounts — staged by a company admin via CSV upload and completed through magic-link redemption (POST /auth/magic-link, below) — accept any E.164 phone number, since a company's roster can include foreign visitors.


POST /auth/register

Register a new user account. Requires at least one of phone or email, a name, and exactly 3 consent entries. On success, an OTP is sent to the provided identifier(s).

Request Body

FieldTypeRequiredDescription
phonestringConditionalSaudi phone in E.164 format (+966XXXXXXXXX). Required if email not provided.
emailstringConditionalEmail address. Required if phone not provided.
namestringYesFull name (1–255 characters)
consentsarrayYesExactly 3 consent objects
FieldTypeRequiredAllowed Values
typestringYesterms_of_service, privacy_policy, data_processing
versionstringYesVersion string (e.g. "1.0")

All 3 consent types must be present exactly once. marketing is a valid ConsentType enum value but is not required for registration.

Examples

Phone-only registration:

json
{
  "phone": "+966501234567",
  "name": "Ahmed Al-Rashid",
  "consents": [
    { "type": "terms_of_service", "version": "1.0" },
    { "type": "privacy_policy", "version": "1.0" },
    { "type": "data_processing", "version": "1.0" }
  ]
}

Email-only registration:

json
{
  "email": "ahmed@example.com",
  "name": "Ahmed Al-Rashid",
  "consents": [
    { "type": "terms_of_service", "version": "1.0" },
    { "type": "privacy_policy", "version": "1.0" },
    { "type": "data_processing", "version": "1.0" }
  ]
}

Dual credential registration (phone + email):

json
{
  "phone": "+966501234567",
  "email": "ahmed@example.com",
  "name": "Ahmed Al-Rashid",
  "consents": [
    { "type": "terms_of_service", "version": "1.0" },
    { "type": "privacy_policy", "version": "1.0" },
    { "type": "data_processing", "version": "1.0" }
  ]
}

Response

201 Created

json
{
  "userId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "message": "OTP sent"
}

Arabic response (Accept-Language: ar):

json
{
  "userId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "message": "تم إرسال رمز التحقق"
}

Error Responses

CodeStatusCondition
AUTH_NO_CREDENTIAL401Neither phone nor email provided
AUTH_PHONE_EXISTS401Phone number already registered
AUTH_EMAIL_EXISTS401Email address already registered
AUTH_CONSENT_REQUIRED401Missing or invalid consent entries
AUTH_RATE_LIMIT_EXCEEDED401Too many OTP requests for this phone or email (max 3 per 15-minute window)
VALIDATION_ERROR400Schema validation failed (invalid phone format, invalid email, etc.)

POST /auth/login

Send an OTP to an existing user. Requires at least one of phone or email. The OTP is delivered via SMS (for phone) or email (for email identifier).

If the phone/email is not registered, the API returns a generic AUTH_INVALID_OTP error (same code as wrong OTP). This prevents phone/email enumeration attacks.

Request Body

FieldTypeRequiredDescription
phonestringConditionalRegistered phone in E.164 format. Required if email not provided.
emailstringConditionalRegistered email address. Required if phone not provided.

Examples

Phone login:

json
{
  "phone": "+966501234567"
}

Email login:

json
{
  "email": "ahmed@example.com"
}

Response

200 OK

json
{
  "message": "OTP sent"
}

Arabic (Accept-Language: ar):

json
{
  "message": "تم إرسال رمز التحقق"
}

Error Responses

CodeStatusCondition
AUTH_NO_CREDENTIAL401Neither phone nor email provided
AUTH_INVALID_OTP401Phone/email not registered (generic to prevent enumeration)
AUTH_RATE_LIMIT_EXCEEDED401Too many OTP requests for this phone or email (max 3 per 15-minute window)
VALIDATION_ERROR400Schema validation failed

POST /auth/verify-otp

Verify an OTP code and create a session. Works for both registration completion and login. Supports phone or email as the identifier.

The response differs by client type, detected from the X-Client-Type header or User-Agent:

  • Mobile (clientType: "mobile"): JWT token returned in response body.
  • Web (clientType: "web"): JWT set as httpOnly cookie named activation-sys-session.

Request Body

FieldTypeRequiredDescription
phonestringConditionalPhone in E.164 format used for OTP. Required if email not provided.
emailstringConditionalEmail address used for OTP. Required if phone not provided.
otpstringYes6-digit numeric OTP code

Examples

Verify with phone:

json
{
  "phone": "+966501234567",
  "otp": "123456"
}

Verify with email:

json
{
  "email": "ahmed@example.com",
  "otp": "123456"
}

Response (Mobile Client)

200 OK

json
{
  "user": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "phone": "+966501234567",
    "name": "Ahmed Al-Rashid",
    "email": "ahmed@example.com",
    "appRole": "end_user",
    "identityType": "citizen"
  },
  "session": {
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "clientType": "mobile",
    "expiresAt": "2026-05-05T09:00:00.000Z"
  }
}

Response (Web Client)

200 OK

json
{
  "user": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "phone": "+966501234567",
    "name": "Ahmed Al-Rashid",
    "email": "ahmed@example.com",
    "appRole": "end_user",
    "identityType": "citizen"
  },
  "session": {
    "clientType": "web",
    "expiresAt": "2026-05-05T09:00:00.000Z"
  }
}

Set-Cookie header: activation-sys-session=<jwt>; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=3600; Priority=High

Email-Only User Response

For email-only users, user.phone is null:

json
{
  "user": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "phone": null,
    "name": "Ahmed Al-Rashid",
    "email": "ahmed@example.com",
    "appRole": "end_user",
    "identityType": "citizen"
  },
  "session": { "..." : "..." }
}

Error Responses

CodeStatusCondition
AUTH_INVALID_OTP401Wrong OTP code, or no user matches the verified identifier
AUTH_EXPIRED_OTP401OTP has expired (TTL: 300 seconds), or the maximum verification attempts were exceeded (max 5 per OTP)
AUTH_ACCOUNT_DEACTIVATED401Account is deactivated (admin deactivation or account-deletion soft-delete) — sign-in is blocked
VALIDATION_ERROR400Schema validation failed, including neither phone nor email provided

POST /auth/magic-link

Redeem a B2B roster invite token and create a session — the sign-in path for CSV-staged company employees/visitors who never went through /auth/register. Public (no auth), rate-limited the same as /auth/verify-otp. The raw token travels only in the JSON body, never a query string or log line.

The response is the same dual-strategy session as /auth/verify-otp (cookie for web, JWT in body for mobile), with an additional b2b object driving the client's next step.

Request Body

FieldTypeRequiredDescription
tokenstringYesThe raw magic-link token from the invite (20–200 characters)

Example Request

json
{
  "token": "a1b2c3d4e5f6...invite-token"
}

Response

200 OK

json
{
  "user": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "phone": "+9665XXXXXXXX",
    "name": "Employee Name",
    "email": null,
    "appRole": "end_user",
    "identityType": "resident"
  },
  "session": {
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "clientType": "mobile",
    "expiresAt": "2026-05-05T09:00:00.000Z"
  },
  "b2b": {
    "nextStep": "iccid_entry",
    "packageId": "pkg-uuid",
    "activationId": null,
    "consentsRequired": ["terms_of_service", "privacy_policy", "data_processing"]
  }
}

b2b.nextStep is one of iccid_entry, identity_entry, identity_waiting, done, computed from the user's most recent activation. b2b.consentsRequired lists which of the 3 required PDPL consents the user has not yet recorded — CSV-staged users have none until they call POST /auth/consents.

Error Responses

CodeStatusCondition
VALIDATION_ERROR400Token missing or outside the 20–200 character range
MAGIC_LINK_INVALID401Token doesn't match any issued magic link
MAGIC_LINK_ALREADY_USED401The link has already been redeemed
MAGIC_LINK_REVOKED401The link was revoked before redemption
MAGIC_LINK_EXPIRED401The link's 24-hour TTL has passed
COMPANY_NOT_APPROVED403The roster entry's company is not currently approved
AUTH_ACCOUNT_DEACTIVATED401The account is deactivated
MAGIC_LINK_USER_ALREADY_ACTIVE409The user's latest activation is already activated — a magic link only onboards a new line

POST /auth/consents

Record the 3 required PDPL consents for the authenticated user. CSV-staged roster users never go through /auth/register, so they never recorded consent — the mobile app calls this once after magic-link redemption, guided by the redemption response's consentsRequired.

Requires authentication.

Request Body

FieldTypeRequiredDescription
consentsarrayYesExactly the 3 required consent objects (see Consent Object)

Example Request

json
{
  "consents": [
    { "type": "terms_of_service", "version": "1.0" },
    { "type": "privacy_policy", "version": "1.0" },
    { "type": "data_processing", "version": "1.0" }
  ]
}

Response

201 Created

json
{
  "data": { "recorded": true }
}

Error Responses

CodeStatusCondition
AUTH_REQUIRED401Missing or invalid token
AUTH_CONSENT_REQUIRED401Missing or invalid consent entries
VALIDATION_ERROR400Schema validation failed

POST /auth/logout

Invalidate the current session.

  • Web: Clears the activation-sys-session httpOnly cookie.
  • Mobile: Adds the JWT to a Redis blocklist (SHA-256 hashed, TTL matching remaining JWT expiry).

Requires authentication.

Response

200 OK

json
{
  "message": "Logged out successfully"
}

Arabic (Accept-Language: ar):

json
{
  "message": "تم تسجيل الخروج بنجاح"
}

Error Responses

CodeStatusCondition
AUTH_REQUIRED401Missing or invalid token

GET /auth/session

Return the current authenticated user's session details.

Requires authentication.

Example Request

GET /auth/session
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Response

200 OK

json
{
  "session": {
    "clientType": "web",
    "expiresAt": "2026-05-05T09:00:00.000Z"
  },
  "user": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "phone": "+966501234567",
    "name": "Ahmed Al-Rashid",
    "email": "ahmed@example.com",
    "appRole": "end_user",
    "managedAreas": null,
    "managedAreasWrite": null,
    "identityType": "citizen",
    "isActive": true,
    "createdAt": "2026-04-30T12:00:00.000Z"
  }
}

appRole is one of end_user, b2b_admin, activation_officer, support, admin, or super_admin. For an admin user, managedAreas / managedAreasWrite carry the assigned portal areas; they are null for end users and fixed staff roles, and super_admin implicitly holds every area.

Error Responses

CodeStatusCondition
AUTH_REQUIRED401Missing or invalid token
AUTH_SESSION_EXPIRED401Session has expired

GET /auth/me

Return the authenticated user's profile with decrypted PII. Functionally equivalent to GET /auth/session.

Requires authentication.

Example Request

GET /auth/me
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Response

200 OK

json
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "phone": "+966501234567",
  "name": "Ahmed Al-Rashid",
  "email": "ahmed@example.com",
  "appRole": "end_user",
  "identityType": "citizen",
  "isActive": true,
  "createdAt": "2026-04-30T12:00:00.000Z"
}

Error Responses

CodeStatusCondition
AUTH_REQUIRED401Missing or invalid token

PUT /auth/me

Update the authenticated user's profile. Only name and email can be updated. Phone changes require a separate OTP flow via POST /auth/change-phone.

Requires authentication.

Request Body

FieldTypeRequiredDescription
namestringNoUpdated name (1–255 characters)
emailstring | nullNoUpdated email (null to remove email)

Example Request

PUT /auth/me
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
json
{
  "name": "Ahmed Mohammed Al-Rashid",
  "email": "ahmed.new@example.com"
}

Response

200 OK

json
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "phone": "+966501234567",
  "name": "Ahmed Mohammed Al-Rashid",
  "email": "ahmed.new@example.com",
  "appRole": "end_user",
  "identityType": "citizen",
  "isActive": true,
  "createdAt": "2026-04-30T12:00:00.000Z"
}

Error Responses

CodeStatusCondition
AUTH_REQUIRED401Missing or invalid token
VALIDATION_ERROR400Invalid name or email format

POST /auth/change-phone

Send a phone-change scoped OTP to a new phone number. The user must then call POST /auth/verify-change-phone with the new phone and OTP to complete the change.

If another account already owns the submitted phone number, the API rejects the request with AUTH_PHONE_EXISTS and does not send an OTP.

Requires authentication.

Request Body

FieldTypeRequiredDescription
newPhonestringYesNew phone in Saudi E.164 format (+966XXXXXXXXX)

Example Request

POST /auth/change-phone
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
json
{
  "newPhone": "+966509876543"
}

Response

200 OK

json
{
  "message": "OTP sent to new number"
}

Arabic (Accept-Language: ar):

json
{
  "message": "تم إرسال رمز التحقق إلى الرقم الجديد"
}

Error Responses

CodeStatusCondition
AUTH_REQUIRED401Missing or invalid token
VALIDATION_ERROR400Invalid phone format
AUTH_PHONE_EXISTS409Phone number is already registered to another account
AUTH_RATE_LIMIT_EXCEEDED401Too many OTP requests for this phone (max 3 per 15-minute window)

POST /auth/verify-change-phone

Verify the OTP sent by POST /auth/change-phone and update the authenticated user's phone number. The OTP challenge is scoped to the phone-change purpose and authenticated user, so login/register OTPs cannot complete this flow. This endpoint derives the user from the JWT context and does not create a new login session.

Requires authentication.

Request Body

FieldTypeRequiredDescription
newPhonestringYesNew phone in Saudi E.164 format (+966XXXXXXXXX)
otpstringYes6-digit OTP sent to newPhone

Example Request

POST /auth/verify-change-phone
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
json
{
  "newPhone": "+966509876543",
  "otp": "654321"
}

Response

200 OK

json
{
  "data": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "phone": "+966509876543",
    "name": "Ahmed Al-Rashid",
    "email": "ahmed@example.com",
    "appRole": "end_user",
    "identityType": "citizen",
    "isActive": true,
    "createdAt": "2026-04-30T12:00:00.000Z"
  },
  "message": "Phone number changed successfully"
}

Arabic (Accept-Language: ar):

json
{
  "data": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "phone": "+966509876543",
    "name": "Ahmed Al-Rashid",
    "email": "ahmed@example.com",
    "appRole": "end_user",
    "identityType": "citizen",
    "isActive": true,
    "createdAt": "2026-04-30T12:00:00.000Z"
  },
  "message": "تم تغيير رقم الهاتف بنجاح"
}

Error Responses

CodeStatusCondition
AUTH_REQUIRED401Missing or invalid token
VALIDATION_ERROR400Invalid phone format or OTP format
AUTH_INVALID_OTP401OTP is incorrect
AUTH_EXPIRED_OTP401OTP expired or max verification attempts exceeded
AUTH_PHONE_EXISTS409Phone number is already registered to another account
AUTH_PHONE_UPDATE_FAILED502Supabase Auth rejected the phone update; local profile was not changed

Complete Auth Flows

Phone-Only Registration

1. POST /auth/register  { phone: "+966501234567", name: "Ahmed", consents: [...] }
   → 201 { userId, message: "OTP sent" }        (OTP via SMS)

2. POST /auth/verify-otp { phone: "+966501234567", otp: "123456" }
   → 200 { user: { phone: "+966501234567", email: null, ... }, session }

Email-Only Registration

1. POST /auth/register  { email: "ahmed@example.com", name: "Ahmed", consents: [...] }
   → 201 { userId, message: "OTP sent" }        (OTP via Email)

2. POST /auth/verify-otp { email: "ahmed@example.com", otp: "123456" }
   → 200 { user: { phone: null, email: "ahmed@example.com", ... }, session }

Dual Credential Registration

1. POST /auth/register  { phone: "+966501234567", email: "ahmed@example.com", name: "Ahmed", consents: [...] }
   → 201 { userId, message: "OTP sent" }        (OTP via SMS AND Email)

2. POST /auth/verify-otp { phone: "+966501234567", otp: "123456" }
   — OR —
   POST /auth/verify-otp { email: "ahmed@example.com", otp: "123456" }
   → 200 { user: { phone: "+966501234567", email: "ahmed@example.com", ... }, session }

Login Flow (Returning User)

1. POST /auth/login     { phone: "+966501234567" }  — or —  { email: "ahmed@example.com" }
   → 200 { message: "OTP sent" }

2. POST /auth/verify-otp { phone/email, otp: "123456" }
   → 200 { user, session }

Phone Change Flow

1. POST /auth/change-phone  { newPhone: "+966509876543" }
   → 200 { message: "OTP sent to new number" }

2. POST /auth/verify-change-phone { newPhone: "+966509876543", otp: "654321" }
   → 200 { data: { phone: "+966509876543", ... }, message: "Phone number changed successfully" }
1. POST /auth/magic-link  { token: "<raw-invite-token>" }
   → 200 { user, session, b2b: { nextStep, packageId, activationId, consentsRequired } }

2. POST /auth/consents { consents: [...] }   — only if consentsRequired is non-empty
   → 201 { data: { recorded: true } }

OTP Rate Limits

ParameterDefaultDescription
OTP_MAX_REQUESTS3Max OTP requests per phone/email per window
OTP_WINDOW_MINUTES15Rate limit window in minutes
OTP_MAX_VERIFY_ATTEMPTS5Max verification attempts per OTP
OTP_TTL_SECONDS300OTP expiry (5 minutes)
OTP_LENGTH6Number of digits in OTP code

App Client Mapping

AppClient typeSession behavior
apps/adminwebhttpOnly cookie session for admin/B2B browser flows
apps/web-b2cwebhttpOnly cookie session for customer browser activation
apps/mobilemobileJWT returned in response body for native storage/authorization header use
apps/sitepublic/unauthenticated by defaultNo auth session unless future public account flows are added

Do not trust a user, company, or role identifier supplied by any client. Ownership and authorization must be derived from the verified session in API middleware/services.

Internal documentation - Activation System