Authentication API
User registration, login, OTP verification, session management, and profile management.
Credential Rules
| Rule | Description |
|---|---|
| At least one required | Registration and login require at least one of phone or email |
| Phone format | Saudi E.164: +966XXXXXXXXX (12 digits, +966 prefix + 9 digits) |
| Email format | Standard RFC 5322 email |
| Dual credential | If both phone and email are provided, OTP is sent via both channels |
| Uniqueness | Phone and email are each globally unique across all users |
| Nullability | phone 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
| Field | Type | Required | Description |
|---|---|---|---|
phone | string | Conditional | Saudi phone in E.164 format (+966XXXXXXXXX). Required if email not provided. |
email | string | Conditional | Email address. Required if phone not provided. |
name | string | Yes | Full name (1–255 characters) |
consents | array | Yes | Exactly 3 consent objects |
Consent Object
| Field | Type | Required | Allowed Values |
|---|---|---|---|
type | string | Yes | terms_of_service, privacy_policy, data_processing |
version | string | Yes | Version 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:
{
"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:
{
"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):
{
"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
{
"userId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"message": "OTP sent"
}Arabic response (Accept-Language: ar):
{
"userId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"message": "تم إرسال رمز التحقق"
}Error Responses
| Code | Status | Condition |
|---|---|---|
AUTH_NO_CREDENTIAL | 401 | Neither phone nor email provided |
AUTH_PHONE_EXISTS | 401 | Phone number already registered |
AUTH_EMAIL_EXISTS | 401 | Email address already registered |
AUTH_CONSENT_REQUIRED | 401 | Missing or invalid consent entries |
AUTH_RATE_LIMIT_EXCEEDED | 401 | Too many OTP requests for this phone or email (max 3 per 15-minute window) |
VALIDATION_ERROR | 400 | Schema 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_OTPerror (same code as wrong OTP). This prevents phone/email enumeration attacks.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
phone | string | Conditional | Registered phone in E.164 format. Required if email not provided. |
email | string | Conditional | Registered email address. Required if phone not provided. |
Examples
Phone login:
{
"phone": "+966501234567"
}Email login:
{
"email": "ahmed@example.com"
}Response
200 OK
{
"message": "OTP sent"
}Arabic (Accept-Language: ar):
{
"message": "تم إرسال رمز التحقق"
}Error Responses
| Code | Status | Condition |
|---|---|---|
AUTH_NO_CREDENTIAL | 401 | Neither phone nor email provided |
AUTH_INVALID_OTP | 401 | Phone/email not registered (generic to prevent enumeration) |
AUTH_RATE_LIMIT_EXCEEDED | 401 | Too many OTP requests for this phone or email (max 3 per 15-minute window) |
VALIDATION_ERROR | 400 | Schema 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 ashttpOnlycookie namedactivation-sys-session.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
phone | string | Conditional | Phone in E.164 format used for OTP. Required if email not provided. |
email | string | Conditional | Email address used for OTP. Required if phone not provided. |
otp | string | Yes | 6-digit numeric OTP code |
Examples
Verify with phone:
{
"phone": "+966501234567",
"otp": "123456"
}Verify with email:
{
"email": "ahmed@example.com",
"otp": "123456"
}Response (Mobile Client)
200 OK
{
"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
{
"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:
{
"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
| Code | Status | Condition |
|---|---|---|
AUTH_INVALID_OTP | 401 | Wrong OTP code, or no user matches the verified identifier |
AUTH_EXPIRED_OTP | 401 | OTP has expired (TTL: 300 seconds), or the maximum verification attempts were exceeded (max 5 per OTP) |
AUTH_ACCOUNT_DEACTIVATED | 401 | Account is deactivated (admin deactivation or account-deletion soft-delete) — sign-in is blocked |
VALIDATION_ERROR | 400 | Schema 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
| Field | Type | Required | Description |
|---|---|---|---|
token | string | Yes | The raw magic-link token from the invite (20–200 characters) |
Example Request
{
"token": "a1b2c3d4e5f6...invite-token"
}Response
200 OK
{
"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
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Token missing or outside the 20–200 character range |
MAGIC_LINK_INVALID | 401 | Token doesn't match any issued magic link |
MAGIC_LINK_ALREADY_USED | 401 | The link has already been redeemed |
MAGIC_LINK_REVOKED | 401 | The link was revoked before redemption |
MAGIC_LINK_EXPIRED | 401 | The link's 24-hour TTL has passed |
COMPANY_NOT_APPROVED | 403 | The roster entry's company is not currently approved |
AUTH_ACCOUNT_DEACTIVATED | 401 | The account is deactivated |
MAGIC_LINK_USER_ALREADY_ACTIVE | 409 | The 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
| Field | Type | Required | Description |
|---|---|---|---|
consents | array | Yes | Exactly the 3 required consent objects (see Consent Object) |
Example Request
{
"consents": [
{ "type": "terms_of_service", "version": "1.0" },
{ "type": "privacy_policy", "version": "1.0" },
{ "type": "data_processing", "version": "1.0" }
]
}Response
201 Created
{
"data": { "recorded": true }
}Error Responses
| Code | Status | Condition |
|---|---|---|
AUTH_REQUIRED | 401 | Missing or invalid token |
AUTH_CONSENT_REQUIRED | 401 | Missing or invalid consent entries |
VALIDATION_ERROR | 400 | Schema validation failed |
POST /auth/logout
Invalidate the current session.
- Web: Clears the
activation-sys-sessionhttpOnly cookie. - Mobile: Adds the JWT to a Redis blocklist (SHA-256 hashed, TTL matching remaining JWT expiry).
Requires authentication.
Response
200 OK
{
"message": "Logged out successfully"
}Arabic (Accept-Language: ar):
{
"message": "تم تسجيل الخروج بنجاح"
}Error Responses
| Code | Status | Condition |
|---|---|---|
AUTH_REQUIRED | 401 | Missing 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
{
"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
| Code | Status | Condition |
|---|---|---|
AUTH_REQUIRED | 401 | Missing or invalid token |
AUTH_SESSION_EXPIRED | 401 | Session 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
{
"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
| Code | Status | Condition |
|---|---|---|
AUTH_REQUIRED | 401 | Missing 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
| Field | Type | Required | Description |
|---|---|---|---|
name | string | No | Updated name (1–255 characters) |
email | string | null | No | Updated email (null to remove email) |
Example Request
PUT /auth/me
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json{
"name": "Ahmed Mohammed Al-Rashid",
"email": "ahmed.new@example.com"
}Response
200 OK
{
"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
| Code | Status | Condition |
|---|---|---|
AUTH_REQUIRED | 401 | Missing or invalid token |
VALIDATION_ERROR | 400 | Invalid 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
| Field | Type | Required | Description |
|---|---|---|---|
newPhone | string | Yes | New phone in Saudi E.164 format (+966XXXXXXXXX) |
Example Request
POST /auth/change-phone
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json{
"newPhone": "+966509876543"
}Response
200 OK
{
"message": "OTP sent to new number"
}Arabic (Accept-Language: ar):
{
"message": "تم إرسال رمز التحقق إلى الرقم الجديد"
}Error Responses
| Code | Status | Condition |
|---|---|---|
AUTH_REQUIRED | 401 | Missing or invalid token |
VALIDATION_ERROR | 400 | Invalid phone format |
AUTH_PHONE_EXISTS | 409 | Phone number is already registered to another account |
AUTH_RATE_LIMIT_EXCEEDED | 401 | Too 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
| Field | Type | Required | Description |
|---|---|---|---|
newPhone | string | Yes | New phone in Saudi E.164 format (+966XXXXXXXXX) |
otp | string | Yes | 6-digit OTP sent to newPhone |
Example Request
POST /auth/verify-change-phone
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json{
"newPhone": "+966509876543",
"otp": "654321"
}Response
200 OK
{
"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):
{
"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
| Code | Status | Condition |
|---|---|---|
AUTH_REQUIRED | 401 | Missing or invalid token |
VALIDATION_ERROR | 400 | Invalid phone format or OTP format |
AUTH_INVALID_OTP | 401 | OTP is incorrect |
AUTH_EXPIRED_OTP | 401 | OTP expired or max verification attempts exceeded |
AUTH_PHONE_EXISTS | 409 | Phone number is already registered to another account |
AUTH_PHONE_UPDATE_FAILED | 502 | Supabase 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" }B2B Magic-Link Sign-In
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
| Parameter | Default | Description |
|---|---|---|
OTP_MAX_REQUESTS | 3 | Max OTP requests per phone/email per window |
OTP_WINDOW_MINUTES | 15 | Rate limit window in minutes |
OTP_MAX_VERIFY_ATTEMPTS | 5 | Max verification attempts per OTP |
OTP_TTL_SECONDS | 300 | OTP expiry (5 minutes) |
OTP_LENGTH | 6 | Number of digits in OTP code |
App Client Mapping
| App | Client type | Session behavior |
|---|---|---|
apps/admin | web | httpOnly cookie session for admin/B2B browser flows |
apps/web-b2c | web | httpOnly cookie session for customer browser activation |
apps/mobile | mobile | JWT returned in response body for native storage/authorization header use |
apps/site | public/unauthenticated by default | No 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.