Auth Module Feature Guide
Functional behavior of admin auth and customer mobile auth — login by email or mobile number, sessions, password reset, and verification.
Auth - Feature Guide
1. Feature Overview
Auth is intentionally split by actor surface:
- Admin/superadmin auth on
/api/auth/*(password sign-in by email address or mobile number) - Customer mobile auth on
/api/mobile/auth/*(password sign-in by email address or mobile number)
There is one password per account, whichever identifier signs in with it. Password reset, verification, one-time codes and invitations are email-based; a mobile number is only ever a second way to name the account at sign-in.
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.
2. Surface Matrix
| Surface | Prefix | Register |
|---|---|---|
| Admin | /api/auth/* | No |
| Customer mobile | /api/mobile/auth/* | No |
3. Account Setup
An administrator creates an account; the person it belongs to finishes it. The account arrives with no password and an unverified address, and one email closes both gaps.
- The administrator grants sign-in access. An invitation email goes out with a single-use link valid for seven days.
- The recipient opens the link. The panel shows Set Your Password — Create a password for your new account to complete your account setup.
- They choose a password and submit. In one transaction the address is marked verified and the password is set.
- They are sent to sign in. The account is ready.
There is no second verification email. Clicking the link is the proof that the mailbox belongs to them, so asking for a further confirmation after the password is set would be asking twice for the same fact. The recipient never passes through forgot-password, reset-password, account-verification or resend-verification on this path.
Setting the password does not create a session. The flow ends at the sign-in screen, so a leaked invitation costs an attacker a password they must still use at the front door.
The address is marked verified only when the invitation's channel is email and
its stored address still equals the account's. An invitation whose address the
account no longer holds is refused outright rather than applied — otherwise a link
sent to somebody's former address could be used to claim the account it was issued
against.
4. Auth Features
Admin and mobile surfaces both support:
- password login by email address or mobile number (§4.1)
- an active role chosen at sign-in, which the two surfaces choose differently for a person holding several roles (§4.2)
- refresh token rotation, with a refresh window that slides forward on every refresh
- token expiry in every login and refresh response —
accessTokenExpiresAtandrefreshTokenExpiresAt— so an app refreshes before its access token lapses instead of finding out from a refused request - logout
- password reset (forgot + reset)
- email verification resend/confirm
- self-service email change (request, confirm by code or link, cancel)
Admin-only extras:
GET /api/auth/permissions- phone verification endpoints under
/api/auth/phone/verify/*
4.1 Signing in by email address or mobile number
A person signs in with their password and one of:
email— the account's email address, the field every existing client sends; oridentifier— an email address or a mobile number, as the person typed it.
Sending both, or neither, is refused with 400 VALIDATION_FAILED. An identifier containing
@ is an email address and signs in exactly as email does. Anything else is a mobile number.
Typing the number. Spaces, dashes, dots and parentheses are ignored, a leading +977,
00977 or 977 in front of ten digits is dropped, and so is one leading 0 in front of ten
digits. What remains must be exactly ten digits. 9800100001, +977 9800100001,
+977-980-0100001, 00977 9800100001 and 09800100001 all name the same number. The stored
number on each account is read the same way, so an account saved as +9779800100001 and one
saved as 9800100001 both answer to any of those spellings. A number that does not come out as
ten digits — a landline, a foreign number, a typo — matches nobody and is answered as a wrong
password.
A shared number. A household often shares one mobile number, so a number alone never picks a person. The password does:
| Accounts that could sign in on the number | Password matches | Outcome |
|---|---|---|
| none | — | 401 AUTH_INVALID_CREDENTIALS |
| one to five | exactly one of them | signed in as that account |
| one to five | none of them | 401 AUTH_INVALID_CREDENTIALS |
| one to five | two or more of them | 401 AUTH_PHONE_LOGIN_AMBIGUOUS |
| more than five | any | 401 AUTH_PHONE_LOGIN_AMBIGUOUS |
AUTH_PHONE_LOGIN_AMBIGUOUS reads "Several accounts use this number. Sign in with your email
instead." — and that is the remedy: the email address always names exactly one account.
"Could sign in" means what signing in by email requires of the account: it is not deleted, not banned, allowed to sign in, and its email address is verified. An account that fails any of these is not a candidate at all — it is never the account a number signs into, it does not count towards the five, and a banned relative's ban reason is never shown to whoever types the shared number. An account with no password counts as a candidate but never matches.
The number itself does not have to be verified. A number an operator types on a person's record is a sign-in key for that person from the moment it is saved, without anyone proving they hold it; the password is still what lets them in. This is a deliberate product decision.
Every attempt by number does the same password-checking work, whatever the answer: five checks run whether the number is unknown, held by one person, shared, or held by more than five. That work dominates the reply time, so timing the reply does not show whether, or by how many people, a number is used. Only a successful sign-in does more, after the decision.
A wrong password costs the account nothing. No account is locked or counted against for a failed attempt by number — a family member mistyping their own password must not lock a relative out. The only limit on guessing is the sign-in rate limit described in §7, the same one email sign-in has.
Signing in by number produces the same session, tokens and response as signing in by email, including the device registration on the mobile route. Forgot-password and reset stay email-only: a person who has forgotten their password resets it through their email address whichever identifier they normally sign in with.
4.2 Which role a person acts as after signing in
A person may hold several roles — a teacher is also staff, and a teacher may also be a
guardian of a pupil at the school. Each session acts as one of them at a time, the active
role, and everything the person may do in that session follows from it.
- Holding one role, a person starts every session with it, on either route.
- Holding several, on the admin panel (
POST /api/auth/login/email), a person starts with no active role and chooses one. Until they do,GET /api/auth/mereportsrequiresRoleSelection: trueand every permission-gated screen is refused with 403AUTH_ACTIVE_ROLE_REQUIRED. - Holding several, on the mobile app (
POST /api/mobile/auth/login/email), a person who holdsteacherstarts asteacher, whatever their other roles are: a member of staff who teaches, a guardian who teaches, and a superadmin who also holdsteacherall land on the teacher screens with nothing to choose, and the sign-in reply'suser.rolesaysteacher. The teacher screens also need a live staff record: a person who starts asteacherwithout one is refused 403PERMISSION_INSUFFICIENTon every teacher screen and switches to another role. Holding several roles withoutteacher, they start with no active role, as on the admin panel. Signing in by email address or by mobile number makes no difference.
Switching. A guardian who teaches reaches their child's screens by switching the session to
guardian with POST /api/auth/context/switch, sending the id of that role from roles on
GET /api/auth/me. The mobile auth surface offers neither a role list nor a switch of its own, so
the app calls those two admin routes with the same token. While teacher is active the guardian
screens answer 403 PERMISSION_INSUFFICIENT. The switch lasts for the session — a token refresh
keeps it — and the next mobile sign-in starts as teacher again. A role the person does not hold
is refused with 403 AUTH_ACTIVE_ROLE_NOT_HELD.
5. Account Entry Links
A person receives an emailed link in two situations, and both end on the same screen: choose a password.
| Purpose | Minted when | Lifetime |
|---|---|---|
account_invite | An administrator grants somebody sign-in access — while creating their record, or afterwards from the people screens | 7 days |
password_reset | Somebody uses forgot-password, or an administrator sends a reset link from a person's detail screen | The OTP expiry, 15 minutes by default |
An invitation redeems through POST /api/auth/invitation/accept and a reset
through POST /api/auth/password/reset, and each link points at its own screen.
The reset endpoint also accepts an account_invite token, so a link pasted into
the wrong screen still works; the invitation endpoint accepts only its own.
Somebody clicking a link cannot tell which of the two they hold, and it makes no difference to what they do next. They stay separate verification records so the two can carry different lifetimes, be audited apart, and so an invitation nobody has redeemed can be reported on.
An invitation is redeemable by link only. A one-time code is minted alongside
it but is never rendered into the invitation email, and the OTP branch of the
reset endpoint matches password_reset alone. That is deliberate rather than an
omission: the OTP lookup selects on purpose and person with no token hash to
separate two live records, so accepting both purposes there would let a correct
code be compared against the wrong record, fail, and consume one of the five
attempts that retire it.
Redeeming either link retires the other. An invitation and an administrator's reset can be live for the same person at the same time, and once one of them has set a password the other must stop working.
A link that has expired, been redeemed, or been retired this way is refused with
AUTH_VERIFICATION_CODE_INVALID. The remedy is a fresh link, not a retry.
6. Self-Service Email Change
A signed-in person changes their own sign-in address from their profile screen, without an
administrator. The panel's own profile PATCH has no email field — the sign-in address changes
only through this flow, so a person cannot slip a new address past the checks below by editing
their profile.
- The person enters the new address and their current password. Getting the current password wrong costs nothing but the mistake: the request is refused before anything is minted, so it never uses up the cooldown or an attempt.
- A confirmation link and a 6-digit code go to the new address, and a notice with a cancel link goes to the old one. Nothing on the account changes yet.
- The person confirms either from inside their session, with the 6-digit code, or from the link itself — the link works without a session, which is why it asks for the current password again: whoever merely reads the new mailbox (a typo, a shared inbox) cannot finish the change alone.
- Confirming moves the address, ends every session including the one that confirmed it, retires every other pending credential (a password reset or another email change), and tells the old address it happened. The person signs in again with the new address.
Cancelling uses the link sent to the old address and only ever deletes a still-pending change; it cannot undo one that already completed. Three distinct outcomes come back, and none reads as another: cancelled, nothing was pending, or the change was already completed (the address is already gone, so the person is told to reset their password and contact the school if they did not make the change).
A change and a code both live for 30 minutes — long enough to open a second inbox, short enough that a stale one is not worth guessing against. Requesting a fresh code or link is limited to one per minute per person, and wrong codes across every reissued attempt are capped at ten per day before the address is locked out for that window; the link's own attempt counter is shared with it, so a wrong password on the link route spends the same budget as a wrong code.
Setting a new password anywhere on the account cancels a pending email change. If somebody starts a change and then changes their password — their own choice, or a reset after a suspected compromise — the pending change is silently retired along with every other session. This is the same reasoning as changing a password ending every other session: the notice already sent to the old address is telling its owner to change the password, and that has to actually stop a change somebody else started with a stolen one.
7. Rate-Limit Feature Notes
Throttling is enabled on high-risk routes (login, refresh, password reset, verification, invitation acceptance).
Each caller gets their own counter, provided the calling app is configured for
it. Limits are keyed on the address the caller came from, and the admin panel is
a proxy, so without configuration the API sees only the panel's own address and
every operator in the school shares one counter — password/forgot becomes three
requests per hour platform-wide. The panel forwards the real address alongside a
shared token (INTERNAL_CLIENT_IP_TOKEN on both sides, and TRUSTED_PROXY_HOPS
set to the number of proxies in front of it that rewrite x-forwarded-for), and
the API honours the address only when that token matches. With the token unset,
behaviour degrades to the single shared counter rather than to an address the
caller can choose.
Sign-in and one-time-code requests are keyed on the address and a hash of the
submitted identifier, so one person failing to sign in cannot lock out everybody
else behind the same address, and an attacker working through a list of addresses
still meets the limit on each one. The identifier — email, then phone, then
identifier — is counted in the form sign-in matches on: an email address trimmed
and lower-cased, and a mobile number reduced to its ten digits, so +977 9800100001,
09800100001 and 9800100001 spend one budget of five. Above that, a ceiling of sixty
sign-in attempts per address per fifteen minutes holds whatever identifiers are sent.
The authentication limiters refuse when Redis is unavailable. There is no account lockout and no failed-attempt counter anywhere in this module, so these limiters are the only bound on password guessing — failing open would remove it silently for the duration of an outage. Every other limiter admits the request and logs, because refusing a legitimate read while a cache is down is the worse outcome.
Auth Module Backend Documentation
Internal auth architecture with split admin/customer controllers for login by email or mobile number, sessions, password reset, and verification.
Admin Route Guarding
How the admin panel decides which pages a role may open — the server guard, the exemption list, the denial and outage destinations, and the failure modes each one exists to prevent.