Skoolsewa - School Management System Docs
Developer Resourcesauth

Auth Module API & Integration Guide

Endpoint reference for admin auth and customer mobile auth — login by email or mobile number, refresh, sessions, password reset, and verification.

Auth - API & Integration Guide

1. Route Surfaces

Auth is split across two surfaces:

  • Admin / backoffice auth: /api/auth/*
  • Customer mobile auth: /api/mobile/auth/*

Accounts are created by an administrator through the Users and Team modules. There is no self-service registration and no self-service account deletion. banned is the only account control.

An administrator-created account arrives with no password and an unverified address. The recipient redeems the invitation link, which verifies the address and sets the first password in one step — see Account invitations. There is no separate verification step before or after it.


2. Endpoint Reference

2.1 Admin auth (/api/auth/*)

MethodPathAuthDescription
POST/api/auth/login/emailPublicAdmin password login by email address or mobile number (§3.1)
POST/api/auth/password/forgotPublicRequest password reset
POST/api/auth/password/resetPublicReset password via token or OTP
POST/api/auth/invitation/acceptPublicRedeem an account invitation: verify the address and set the first password
POST/api/auth/password/setJWTSet or change password for authenticated user
POST/api/auth/email/verify/resendPublicResend email verification
POST/api/auth/email/verify/confirmPublicConfirm email verification, by link or by { email, otp }
POST/api/auth/email/changeJWTRequest a change of sign-in address
POST/api/auth/email/change/confirm-codeJWTConfirm a pending email change with the 6-digit code
POST/api/auth/email/change/confirmPublicConfirm a pending email change with the link token
POST/api/auth/email/change/cancelPublicCancel a pending email change from the old address's link
POST/api/auth/refreshPublicRotate refresh/access tokens (§3.1a)
GET/api/auth/meJWTGet current user profile (includes hasPassword)
GET/api/auth/permissionsJWTGet the active role's permission codes; a superadmin receives the catalogue minus the person-bound screen codes (§3.5)
POST/api/auth/phone/verify/requestJWTSend phone verification OTP
POST/api/auth/phone/verify/confirmJWTConfirm phone verification OTP
DELETE/api/auth/logoutJWTLogout current session

2.2 Customer mobile auth (/api/mobile/auth/*)

MethodPathAuthDescription
POST/api/mobile/auth/login/emailPublicCustomer password login by email address or mobile number (§3.1)
POST/api/mobile/auth/refreshPublicRotate refresh/access tokens (§3.1a)
POST/api/mobile/auth/password/forgotPublicRequest password reset
POST/api/mobile/auth/password/resetPublicReset password via token or OTP
POST/api/mobile/auth/password/setJWTSet password for authenticated mobile user
POST/api/mobile/auth/email/verify/resendPublicResend email verification
POST/api/mobile/auth/email/verify/confirmPublicConfirm email verification, by link or by { email, otp }
POST/api/mobile/auth/email/changeJWTRequest a change of sign-in address
POST/api/mobile/auth/email/change/confirm-codeJWTConfirm a pending email change with the 6-digit code
POST/api/mobile/auth/email/change/confirmPublicConfirm a pending email change with the link token
POST/api/mobile/auth/email/change/cancelPublicCancel a pending email change from the old address's link
GET/api/mobile/auth/meJWTGet current user profile (includes hasPassword)
DELETE/api/mobile/auth/logoutJWTLogout current session

Both mounts share the same AuthEmailChangeService and AuthService, so a route behaves identically on either prefix; /api/mobile/auth/* exists so the mobile Swagger document carries these operations under its own tag rather than the admin one.


3. Key Contracts

3.1 Login by email address or mobile number

POST /api/auth/login/email and POST /api/mobile/auth/login/email. The path keeps its name for compatibility; both routes accept either identifier.

Request body

FieldTypeRequiredNotes
emailstringExactly one of email / identifierThe account's email address. On the mobile route it must be a valid address (@IsEmail); the admin route passes it to the lookup as sent
identifierstringExactly one of email / identifier"Email address or mobile number". Trimmed, then 3–254 characters. A value containing @ is an email address and behaves exactly like email; anything else is a mobile number
passwordstringYesThe account's password. 8–32 characters on the mobile route
deviceInfoobjectMobile route only{ deviceId, deviceType, deviceName? }; deviceType android or ios registers the device and makes the session a mobile session

Email, as every existing client sends it:

{
  "email": "customer@example.com",
  "password": "StrongPass123",
  "deviceInfo": {
    "deviceId": "android-abc",
    "deviceType": "android",
    "deviceName": "Pixel"
  }
}

Mobile number (the admin route takes the same body without deviceInfo):

{
  "identifier": "+977 980-0100001",
  "password": "StrongPass123",
  "deviceInfo": {
    "deviceId": "android-abc",
    "deviceType": "android"
  }
}

How a mobile number is read. Spaces, dashes, dots and parentheses are removed; a leading +977, 00977 or 977 is dropped when exactly ten digits follow, then one leading 0 when exactly ten digits follow. The result must be exactly ten digits. 9800100001, +977 9800100001, +977-980-0100001, 00977 9800100001 and 09800100001 are the same number, and the number stored on each account is read the same way. Anything that does not come out as ten digits matches nobody.

Which account signs in. Several accounts may share a number. Of the accounts on the number that could sign in by email — not deleted, not banned, allowed to sign in, email address verified, password set — the one whose password matches signs in. Five is the most a number may carry; more than five is refused before any password is checked.

Response. Identical in shape to an email sign-in — 200 with { message: "Login successful.", data: { user, tokens: { sessionId, accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } } }. The tokens object is described field by field in §3.1a.

Which role the session starts with. Every permission check reads the session's active role, and the two routes choose it differently:

Roles the person holdsPOST /api/auth/login/emailPOST /api/mobile/auth/login/email
noneno active roleno active role
exactly onethat rolethat role
several, teacher among them (for example staff + teacher, guardian + teacher, superadmin + teacher)no active roleteacher
several, no teacherno active roleno active role

Signing in by email address and by mobile number choose the same way.

data.user.role in the sign-in reply is the name of the role the session starts with when it starts with one — teacher for a staff + teacher or guardian + teacher holder on the mobile route. With no active role it is the name of one of the roles the person holds, in no defined order, or null when they hold none; read requiresRoleSelection and roles from GET /api/auth/me in that case.

With no active role, GET /api/auth/me reports requiresRoleSelection: true and every permission-gated route answers 403 AUTH_ACTIVE_ROLE_REQUIRED. With teacher active, the teacher routes (/api/mobile/teacher/*) need no switch first, and the guardian routes (/api/mobile/guardian/*) answer 403 PERMISSION_INSUFFICIENT until the session switches to guardian.

A person who wants another role switches the session with POST /api/auth/context/switch and body { "roleId": <id> }, taking the id from roles on GET /api/auth/me. The mobile auth surface has no switch route and lists no roles — GET /api/mobile/auth/me reports only role, the active role's name — so the app calls the admin routes for both, with the same bearer token. The switch belongs to the session: a refresh keeps it, and the next sign-in on the mobile route starts as teacher again. A roleId the caller does not hold answers 403 AUTH_ACTIVE_ROLE_NOT_HELD.

Errors

StatuserrorCodeWhenWhat the client should do
400VALIDATION_FAILEDBoth email and identifier sent, or neither; identifier not a string of 3–254 characters after trimming; mobile route only — email not an address, password outside 8–32, deviceInfo invalid, an unknown fieldFix the request
401AUTH_INVALID_CREDENTIALSWrong password; unknown address or number; a number that is not ten digits once normalised; an account that may not sign inShow a generic "incorrect details" message — it deliberately does not say which part was wrong
401AUTH_PHONE_LOGIN_AMBIGUOUSThe password matches more than one account on the number, or more than five eligible accounts share it. Message: "Several accounts use this number. Sign in with your email instead."Ask the person to sign in with their email address
401AUTH_EMAIL_VERIFICATION_PENDINGEmail path only: the address is not verified yetPrompt the person to verify their address
401AUTH_ACCOUNT_BANNEDEmail path only: the account is bannedShow the message; the account cannot sign in
429RATE_LIMIT_EXCEEDEDThe AUTH_ATTEMPT rate limit: five per caller address and submitted identifier, sixty per address, each per fifteen minutesWait and retry

On the mobile-number path an unverified or banned account is simply not a candidate, so those two codes never come back from it — the attempt answers as if that account were absent. The phone number itself does not need to be verified: any number saved on an account signs in to it with that account's password.

The AUTH_ATTEMPT limit counts a mobile number by its ten digits, not by how it was typed: 9800100001, +977 9800100001 and 09800100001 share one budget of five. An email address is counted trimmed and lower-cased. Every attempt by number performs the same five password checks before deciding, so that work — the bulk of the reply time — does not depend on how many accounts hold the number.

Password reset (password/forgot, password/reset) stays email-based; there is no reset by mobile number.

3.1a The tokens object, and refreshing it

Both login routes and both refresh routes return the same tokens object (AuthTokensDto). Every field is always present.

FieldTypeNotes
sessionIdstring (UUID)The session's id. A refresh keeps it; send it with every refresh
accessTokenstringThe bearer JWT, sent as Authorization: Bearer <accessToken>
refreshTokenstringSingle use. A refresh returns a new one, and the one sent stops working
accessTokenExpiresAtstring, ISO-8601 date-time in UTCWhen the access token expires: the moment it was issued plus JWT_ACCESS_TOKEN_TTL_SECONDS (900 seconds by default)
refreshTokenExpiresAtstring, ISO-8601 date-time in UTCWhen the refresh token expires: the moment it was issued plus JWT_REFRESH_TOKEN_TTL_SECONDS (604800 seconds, seven days, by default). It is the session's own expiry

Both timestamps are written with millisecond precision and a Z suffix, for example 2026-10-08T10:15:00.000Z, and both come from the server's clock.

Refreshing. POST /api/auth/refresh takes refreshToken and sessionId; POST /api/mobile/auth/refresh takes the same two fields plus deviceInfo. Both answer 200 with { message: "Tokens refreshed.", data: { user, tokens } }.

A refresh keeps sessionId, issues a new access token and a new refresh token, and sets the session's expiry to one refresh lifetime after the refresh. The refreshTokenExpiresAt it returns is that new expiry, so the window slides with use: a session lapses only after a full refresh lifetime with no refresh.

A tokens object returned by a refresh at 2026-10-08T10:00:00Z, with the default lifetimes:

{
  "sessionId": "0199c3a2-5d1e-7b40-9a61-2f4c8e7d1a90",
  "accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refreshToken": "9f2c4e...",
  "accessTokenExpiresAt": "2026-10-08T10:15:00.000Z",
  "refreshTokenExpiresAt": "2026-10-15T10:00:00.000Z"
}

When to refresh. Refresh shortly before accessTokenExpiresAt rather than waiting for a 401, leaving a margin for a device clock that differs from the server's. Send one refresh at a time and keep the refreshToken it returns: presenting a refresh token a refresh has already replaced ends the session.

Errors

StatuserrorCodeWhenWhat the client should do
400VALIDATION_FAILEDrefreshToken or sessionId missing or not a string; an unknown field; on the mobile route, an invalid deviceInfoFix the request
401AUTH_SESSION_NOT_FOUNDNo session has that sessionId — it was logged out, or ended by one of the refusals belowSign in again
401AUTH_SESSION_EXPIREDThe session is past refreshTokenExpiresAt. The session is endedSign in again
401AUTH_REFRESH_TOKEN_MISMATCHThe refreshToken is not the session's current one. The session is endedSign in again
401AUTH_SESSION_ACTOR_NOT_FOUNDThe session's user no longer exists. The session is endedSign in again
401AUTH_ACCOUNT_BANNEDThe account is bannedShow the message; the account cannot sign in
429RATE_LIMIT_EXCEEDEDThe AUTH_TOKEN_REFRESH rate limit: ten refreshes a minuteWait and retry with the same tokens

3.2 Password reset, and account invitations

POST /api/auth/password/reset and POST /api/mobile/auth/password/reset redeem an emailed credential and set a password. Send either token or otp, never neither:

FieldTypeRequiredNotes
tokenstringOne of token/otpThe value from the emailed link
otpstringOne of token/otpExactly six digits
emailstringWith otp, if no phoneIdentifies whose code this is
phonestringWith otp, if no emailE.164; validated on the way in
passwordstringYes8-32 characters, and must satisfy the strong-password rule

token accepts either account-entry purpose — a password_reset or an account_invite. Somebody who has been sent a link cannot tell which of the two they hold, and both mean the same thing from their side: choose a password. An invitation is valid for 7 days; a reset for the OTP expiry, 15 minutes by default.

An invitation redeemed here also marks the address verified, on the same condition the dedicated endpoint applies: the record's channel is email and its stored address still equals the account's. /api/auth/invitation/accept is the endpoint the invitation email points at, and the one to integrate against; this branch exists so a link pasted into the reset screen still works.

otp accepts password_reset only. Account invitations are redeemable by link only. The code is not printed in the invitation email, and the OTP lookup matches on purpose and person without a token hash to tell two live records apart — so accepting both purposes there could check a correct code against the wrong record, fail, and spend one of the five attempts that retire it.

Redeeming either credential retires the person's other account-entry credential, so an invitation and an administrator-issued reset cannot both stay usable once one of them has set a password.

Errors:

  • 400 AUTH_PASSWORD_RESET_INPUT_REQUIRED — neither token nor otp was sent
  • 400 AUTH_OTP_REQUIRED — the OTP branch was taken with no otp
  • 400 AUTH_OTP_TARGET_REQUIRED — otp sent with neither email nor phone
  • 400 AUTH_VERIFICATION_CODE_INVALID — the token or code is unknown, expired, already redeemed, or was retired by the person redeeming their other link. Ask for a fresh link rather than retrying this one.

Every successful reset notifies the account holder by email, including one they asked for themselves. That notification is the only signal a person gets that somebody else changed their password, so it is not suppressed for the expected case. Delivery failure never rolls back the password change.

Setting or resetting a password anywhere on the account retires any pending email change (§3.3a) as part of the same write, on top of ending every other session.

POST /api/auth/email/verify/confirm and its mobile mount accept either the link's token, or the account's email plus the 6-digit otp from the same email — a phone app has no page to open a link on, so it uses the second form; the panel's link page uses the first.

FieldTypeRequiredNotes
tokenstringWith no email/otpThe value from the verification link
otpstringWith email, and no tokenExactly six digits
emailstringWith otpThe account's address

Every failure — an unknown token, a wrong code, an expired or already-used record, or a record whose target is no longer the account's current address — answers the same 400 AUTH_VERIFICATION_CODE_INVALID, so the endpoint cannot be used to learn which addresses have accounts or which addresses are pending elsewhere. POST /api/auth/email/verify/resend answers identically (200, no body change) for an unknown address, an already-verified one, and a real send.

3.3 Account invitations

POST /api/auth/invitation/accept is the whole of account setup. It verifies the email address and sets the first password in one transaction, and the account is usable immediately afterwards — there is no second verification email, and the recipient is never sent to the forgot-password, reset-password, verify-account or resend-verification screens.

Public. The recipient has no session; the authorization statement is possession of the token, which is 48 bytes of entropy and single-use.

FieldTypeRequiredNotes
tokenstringYesThe value from the invitation link's token query parameter
passwordstringYes8-32 characters, and must satisfy the strong-password rule

There is no otp field and no email field. An invitation is redeemable by link only — its OTP is minted but never rendered into the email, and the OTP lookup is scoped to password_reset, so a code presented here could never select the record it names. The token already carries the identity, so asking for an address beside it would only invite a caller to disagree with it.

Response (200 OK) carries no tokens:

{
  "message": "Your account is ready. Sign in with your new password.",
  "data": null,
  "errorCode": null
}

Accepting an invitation deliberately does not mint a session. The flow ends at the sign-in screen, so a leaked invitation costs an attacker a password they must then use at the front door rather than a session they already hold.

Errors:

StatuserrorCodeMeaningWhat the consumer should do
400AUTH_ACCOUNT_INVITE_EXPIREDThe link is older than seven days, or the token is unknownAsk an administrator to send a fresh invitation
400AUTH_ACCOUNT_INVITE_ALREADY_USEDThe password has already been set from this linkSend the person to sign in
400AUTH_ACCOUNT_INVITE_REVOKEDThe account was disabled after the invitation was sent (canLogin is false)Contact an administrator; a fresh invitation will not help
400AUTH_VERIFICATION_CODE_INVALIDThe account's address no longer matches the one the invitation was minted forAsk for a fresh invitation to the current address
401AUTH_ACCOUNT_BANNEDThe account was banned after the invitation was sentContact an administrator
429—Rate limit exceededTen attempts per fifteen minutes per caller

The token is single-use and the claim is atomic: two requests redeeming one link concurrently produce exactly one success and one AUTH_ACCOUNT_INVITE_ALREADY_USED. Redeeming it also retires the person's other account-entry credentials, so an administrator-issued reset sent alongside an invitation cannot stay live once either has been used.

Address changes invalidate the invitation. The endpoint asserts that the account's current address still equals the one the invitation was minted for, rather than assigning the invitation's address to the account. An invitation sent to an address the account no longer holds is refused, which is what stops a link sent to a former address from taking over the account it was issued against.

3.4 Logout

DELETE /api/auth/logout and DELETE /api/mobile/auth/logout end the calling session with reason user_logout. The same session-ending path runs internally, with reason session_ended, when POST /api/auth/refresh or POST /api/mobile/auth/refresh finds the session expired, the presented refresh token mismatched, or the user record missing — never as a result of somebody choosing to sign out.

Ending a session also retires that session's push tokens, recording the same reason on them, because a token stays deliverable only for as long as the session that registered it is alive. See the notification device endpoints for the full lifecycle; this route's own response is unaffected either way.

3.3a Self-service email change

Four routes under email/change, both mounts. No parameterised route shares the prefix, so none can shadow another.

POST /api/auth/email/change

JWT. Rate limit: AUTH_EMAIL_CHANGE_REQUEST — 8 requests an hour per person (auth tier — fails closed on a Redis outage). Refused requests count, and "Send a new code" comes back through this route. Separately, one code is issued per person per 60 seconds; a second request inside that window answers 429 AUTH_VERIFICATION_RESEND_COOLDOWN.

{
  "newEmail": "new.address@example.com",
  "currentPassword": "CurrentPass@123"
}
FieldTypeRequiredNotes
newEmailstringYesTrimmed and lower-cased; must be a plain dot-atom address (local@domain.tld) — a quoted local part, which @IsEmail() alone would accept, is refused because it can carry a CR/LF
currentPasswordstringYesUp to 128 characters

Response (200 OK):

{
  "message": "We sent a confirmation link and code to the new address.",
  "data": { "sentTo": "n•••@example.com", "expiresAt": "2026-09-25T14:30:00.000Z" },
  "errorCode": null
}

Nothing on the account changes yet. A confirmation link and a 6-digit code go to the new address; if the account already has a verified address, a notice with a cancel link goes there too.

Errors:

StatuserrorCodeMeaningWhat the consumer should do
400AUTH_EMAIL_UNCHANGEDnewEmail is already the account's addressNothing to do
400AUTH_CURRENT_PASSWORD_REQUIREDThe account has no password setSet a password first
400AUTH_CURRENT_PASSWORD_INCORRECTWrong current passwordAsk again; this does not spend an attempt or the cooldown
409AUTH_EMAIL_ALREADY_EXISTSAnother account already holds newEmailChoose a different address
429AUTH_VERIFICATION_RESEND_COOLDOWNA code was requested for this account within the last 60 secondsWait, then retry
429—Rate limit exceededBack off

POST /api/auth/email/change/confirm-code

JWT. Rate limit: AUTH_OTP_VERIFY (auth tier).

{ "otp": "123456" }

Confirms the caller's own pending change. Every session ends, including this one — sign in again with the new address.

Response (200 OK):

{
  "message": "Email changed. Sign in again with your new address.",
  "data": { "email": "new.address@example.com" },
  "errorCode": null
}

Errors: 400 AUTH_VERIFICATION_CODE_INVALID (unknown/wrong/expired code, or no pending change), 400 AUTH_VERIFICATION_ATTEMPTS_EXCEEDED (ten wrong codes across any number of reissues within 24 hours, or the per-record cap), 403 AUTH_ACCOUNT_BANNED, 400 AUTH_ACCOUNT_SIGN_IN_REVOKED, 409 AUTH_EMAIL_ALREADY_EXISTS (another account took the address after the request was made).

POST /api/auth/email/change/confirm

Public. Rate limit: AUTH_ATTEMPT_GUEST. Opened from the link, without a session — so the current password is required too, or whoever merely reads the new mailbox could complete the change alone.

{ "token": "<from the link>", "currentPassword": "CurrentPass@123" }

Same response shape and the same session-ending behaviour as confirm-code.

Errors: the confirm-code set above, plus 401 AUTH_ACCOUNT_BANNED (a 401 here, not the 403 the signed-in route uses, because there is no session to distinguish it from) and 400 AUTH_CURRENT_PASSWORD_INCORRECT — a wrong password here spends one of the record's attempts, the same budget a wrong code would.

POST /api/auth/email/change/cancel

Public. Rate limit: AUTH_ATTEMPT_GUEST. Opened from the cancel link sent to the old address.

{ "token": "<from the cancel link>" }

Response (200 OK) — three outcomes, distinguished only by data.cancelled and status:

{ "message": "The email change was cancelled.", "data": { "cancelled": true }, "errorCode": null }

cancelled: false means nothing was pending (already cancelled, expired, or the token does not resolve). A change that had already been confirmed is not reported as cancelled: false — it is a distinct 409, because the address is already gone and the caller needs to know that resetting the password, not retrying this link, is the next step:

{
  "message": "This change was already completed. If you did not make it, reset your password and contact the school.",
  "data": null,
  "errorCode": "AUTH_EMAIL_CHANGE_ALREADY_COMPLETED"
}

Every route in this group lives on AuthEmailChangeService, shared byte-for-byte between /api/auth/* and /api/mobile/auth/*. See the backend page for the transaction shape, lock order, and the Redis-backed cooldown and lockout these routes share with every other verification record.

3.5 GET /api/auth/permissions

Returns the permission codes of the caller's active role as a flat string[] in the standard envelope. It never returns the union of every role the account holds.

Active roledata
None (no active role on the token)[]
A role with isSuperadmin: trueEvery catalogue code except the person-bound screen codes
Any other roleThe codes granted to that role in role_permission
{
  "message": "Permissions fetched.",
  "data": ["Students_READ", "Students_CREATE", "NoticeView_READ", "..."]
}

The superadmin list. buildSuperadminPermissionCatalog() in packages/db/src/authorization/permission-catalog.ts builds it: the full catalogue with every code of the modules in PERSON_BOUND_SCREEN_MODULES removed — Schedule, ExamView, AcademicHistory, AttendanceView, LeaveView, MyFees, MySalary, MyAssets, AssignmentView and MyReports, all five actions of each. Those codes gate the panel's "My …" screens, which show the caller's own student, guardian or staff record, and a superadmin account has none. NoticeView and MyComplaints stay in the list: notices reach a superadmin through the all_users, role and users audiences, and complaints have an admin filer path for an account with no staff row.

The list is narrower than what the route guard enforces, and only in the non-granting direction. RoleGuard still admits a superadmin active role to every route, so no API route changes behaviour. The panel builds its sidebar and its page gates from this list alone, which is what hides the screens. A superadmin who also holds a staff or teacher role switches active role to reach them.


4. Set Password

Authenticated endpoints for setting or changing a user's password.

4.1 POST /api/auth/password/set

4.2 POST /api/mobile/auth/password/set

Set or change password for an authenticated user.

Authentication: JWT Bearer token

Rate limit: 5 requests per 15 minutes

Request body:

{
  "newPassword": "NewPass@1234",
  "currentPassword": "CurrentPass@123"
}

currentPassword is required only if the user already has a password.

Response (200 OK):

{
  "message": "Password set successfully.",
  "data": null,
  "errorCode": null
}

Errors:

  • 400 Bad Request — currentPassword missing when user already has password
  • 401 Unauthorized — Invalid JWT or incorrect currentPassword
  • 429 Too Many Requests — Rate limit exceeded

5. hasPassword Field

All profile responses from:

  • GET /api/auth/me
  • GET /api/mobile/auth/me

include hasPassword: boolean.

  • hasPassword: false means no EMAIL provider password exists yet.
  • hasPassword: true means EMAIL provider password is set.

Frontend integration:

  • hasPassword === false → show "Add Password"
  • hasPassword === true → show "Change Password"