Auth Module Backend Documentation
Internal auth architecture with split admin/customer controllers for login by email or mobile number, sessions, password reset, and verification.
Auth - Backend Documentation
1. Module Scope
Shared AuthModule provides auth primitives used by two HTTP surfaces:
- Admin controller:
/api/auth/* - Mobile customer controller:
/api/mobile/auth/*
Core responsibilities:
- password auth, with the account named by email address or by mobile number (§3.7)
- session/token issuance + refresh/logout
- email/phone verification flows
- password reset flows
Accounts are provisioned by an administrator through the Users and Team modules. There is no self-service registration and no self-service account deletion.
2. Composition
2.1 Controllers
Admin / backoffice, under apps/api/src/modules/auth/:
auth-session.controller.ts,auth-password.controller.ts,auth-verification.controller.ts,auth-invitation.controller.ts
auth-invitation.controller.ts is a separate class rather than a method on
auth-password.controller.ts because @nestjs/swagger merges array-valued
metadata: a method-level @ApiTags on a class that already carries one yields two
tags on that operation, and swagger-coverage.spec.ts asserts exactly one. A
second tag needs a second class.
Customer / mobile, under apps/api/src/modules/mobile/auth/:
auth-session,auth-password,auth-verificationcontrollers
2.2 Strategies and guards
LocalAuthGuard(guards/local-auth.guard.ts) for admin login. The admin login route binds no DTO, so the guard is the only code that reads its body: it refuses a body carrying both or neither ofemailandidentifier(400VALIDATION_FAILED), hands anemailbody to the passportemailstrategy (LocalStrategy) unchanged, and validates anidentifierbody itself (trimmed length 3–254, a non-emptypassword) before callingAuthService.validateIdentifierLogin. Either way the profile lands onrequest.user. It deliberately does not apply the wholeEmailLoginDtoto anemailbody — doing so would turn requests the route has always answered into 400s.JwtStrategy+JwtAuthGuardfor protected routes
2.3 Core services
AuthService— thin facade; delegates to the collaborators belowAuthPasswordService— password reset/set flowsAuthInvitationService— redeems an account invitation: verifies the address and sets the first password in one transactionAuthEmailChangeService— self-service change of the sign-in address: request, confirm by code or link, cancel. See §3.5.AuthLoginService— credential validation and login, by email address or by mobile number (§3.7), and the choice of the role a new session starts with (§3.9)AuthSessionService,AuthUserQueryService,AuthTokenService,AuthEmailService,VerificationTokenService— the last of these mints, redeems, and retires the emailed verification records described in §3.2, and is consumed by the People module as well as by auth's own routes
Every auth email shows the school's name and logo from the School Profile rather than a
fixed product name. The emails are notification kinds (auth.password_reset,
auth.account_invite, auth.password_changed, auth.email_change_confirm,
auth.email_change_requested, auth.email_changed, auth.email_verification, and the
auth.otp SMS), so the notification renderer supplies the brand when the channel worker
renders them. The name falls back to EMAIL_BRAND_NAME, then "Skoolsewa", and a missing
logo shows the name as text; a missing or unreadable profile never prevents a send. The
standalone builders in modules/auth/emails/ take an optional brand ({ name, logoUrl })
and name the school in the subject ("Reset your {name} password", "Your {name} password was changed"); without one they use the same fallback. See the notification backend page,
§6.4.1, for how the brand is resolved and held for 60 seconds.
3. Behavior Clarifications
3.1 Banned accounts
banned is the only account-level control. A banned account cannot authenticate.
3.2 Account-entry purposes
VerificationTokenService mints one row per emailed credential, keyed by
(identifier, purpose). Two of the verification_purpose values grant entry to
an account by link:
VerificationTokenService.ACCOUNT_ENTRY_PURPOSES = ["password_reset", "account_invite"]
password_reset— forgot-password, and the administrator-issued reset link fromPeopleAccountService.sendPasswordResetLink. Expires afterOTP_EXPIRY_MINUTES, 15 minutes by default.account_invite— minted byPeopleAccountServicewhen an administrator grants somebody sign-in access. It passesexpiresInMs: ACCOUNT_INVITE_TTL_MS, seven days, because a token that dies before the recipient next opens their inbox is not an invitation.
AuthPasswordService.resetPassword resolves the token branch through
consumeByToken(ACCOUNT_ENTRY_PURPOSES, token). Widening the purpose there is
safe because the predicate still selects on the token hash, which identifies
exactly one row; the purpose list only widens which rows are eligible.
consumeByOtp stays scoped to password_reset for the inverse reason. It selects
on (purpose, identifier) with limit(1) and no ordering, so a purpose list
would return an arbitrary row when somebody holds both an invitation and a reset.
A wrong row means a correct code fails to match, incrementAttempts fires against
it, and five of those retire the record. An invitation therefore redeems by link
only — its OTP is minted, because the factory always mints one, but it is never
rendered into the email and this predicate can never select it.
consumeAccountEntry(recordId, userId, executor?) marks the redeemed record
consumed and deletes every other account-entry record for the same person.
clearExisting is keyed on (identifier, purpose), so an invitation and a reset
coexist as two independently live single-use credentials; choosing one has to
retire the other, or the older link keeps working after a password has already
been set through the newer one.
Both statements run in one transaction. They are the same act: run separately, a failure between them leaves the sibling live — precisely the credential this method exists to retire, now with the password already changed. The executor is threaded so a caller that already owns a transaction keeps ownership of it.
The claim is also the concurrency gate. The UPDATE sets consumed_at only
WHERE consumed_at IS NULL and returns the affected rows, so the method returns a
boolean and both call sites check it. consumeByToken is an unlocked read
outside any transaction; without the boolean, two concurrent submissions of one
link would both pass it and both write a password.
claim(recordId, executor?) is the same single-use gate for any other purpose —
AuthEmailChangeService.complete uses it rather than consumeAccountEntry,
because an email change has no sibling purpose to retire in the same statement.
countAttempt spends one attempt before the code is even compared, on its own
committed UPDATE … WHERE consumed_at IS NULL AND attempts < max_attempts. Two
properties follow: parallel guesses against one record cannot exceed
max_attempts, because the increment is the gate rather than a read of the
counter; and a caller that fails later inside its own transaction cannot roll the
attempt back, closing the earlier hole where a wrong code threw, the transaction
rolled back, and guessing was unbounded. confirmByToken (§3.5) calls it directly
to spend an attempt on a wrong password, since the link route has no code of its
own to check.
A per-(purpose, person) Redis counter sits above the per-record cap, because
the per-record max_attempts resets with every new code and on its own lets a
guesser spread attempts across as many codes as they can request. Ten failed
codes across any number of reissues, within a rolling 24 hours, locks that
(purpose, person) pair out with AUTH_VERIFICATION_ATTEMPTS_EXCEEDED regardless
of how many live records exist. reserveIssue(purpose, userId) is the matching
control on the issue side: a new code for the same (purpose, person) is
refused with 429 AUTH_VERIFICATION_RESEND_COOLDOWN inside 60 seconds of the last
one. Both counters fail open when Redis is unavailable — the per-record cap in
Postgres still applies, and refusing every code during a cache outage would lock
people out of their own accounts. This is the opposite failure direction from
IpThrottlerGuard's auth tiers (§4.1), which fail closed, because those are the
only bound on guessing where this pair is a second one layered above a cap that
already exists.
3.3 Accepting an invitation
AuthInvitationService.acceptInvitation is the endpoint the invitation email
points at. It resolves the token through consumeInvitationToken, which is scoped
to account_invite alone, and maps the generic verification refusals onto the
invitation-specific codes — AUTH_VERIFICATION_EXPIRED becomes
AUTH_ACCOUNT_INVITE_EXPIRED, AUTH_VERIFICATION_ALREADY_USED becomes
AUTH_ACCOUNT_INVITE_ALREADY_USED. Both codes existed in the registry with no
thrower before this endpoint did.
Inside one transaction, in this order:
SELECT … FOR UPDATEon theusersrow.- The four refusals, re-asserted against the locked row: banned,
canLoginfalse, and the account'semailno longer equal to the invitation's target. - The atomic claim (
consumeAccountEntry), whose boolean is checked. updatePasswordForUser, on the same executor.users.email_verified, written only when the record's channel isemailand its address still equals the account's.
The address is asserted, never assigned. Writing the invitation's address onto
the account would mean a link issued against a former address could be used to
take the account over; comparing instead makes a stale invitation simply invalid.
Comparison is on trim().toLowerCase(), matching the email_normalized generated
column's lower(btrim(email)), so the two can never disagree about what "the same
address" means.
Lock order is users first, then the verification claim. PeopleAccountService
takes the same two in the same order. Taking them in the opposite order here would
be a deadlock (40P01) between an administrator re-issuing an invitation and its
recipient redeeming it.
emailVerified is written last on purpose. It is the only one of the five steps
that is not strictly required to make the account usable, and ordering it after the
password means a fault in it cannot leave a verified address with no password.
The endpoint returns no tokens. Accepting an invitation does not mint a session.
3.4 Logout, and what it does to push tokens
AuthService.logout(sessionId, reason) takes a reason of "user_logout" (the
default, for a person signing out through DELETE /api/auth/logout or
DELETE /api/mobile/auth/logout) or "session_ended" (used internally by the
refresh path when a session is ended because it expired, the presented refresh
token did not match, or no session id was supplied at all — never because
somebody chose to sign out).
After deleting the session row, logout marks every active push token
registered under that session as invalidated, recording the same reason on
the token that ended the session. This is tidy-up rather than the control: a
push read already excludes a token whose registering session is gone or
expired, so a lock or an error here is logged and swallowed rather than
allowed to fail the logout itself. See the notification module's push token
lifecycle for the column
and the device endpoints
for the client-facing contract — this module only records why a session
ended, and never mutates notification_push_token on any path but this one.
3.5 Self-service email change — AuthEmailChangeService
Three entry points, one shared completion path.
request(actor, newEmail, currentPassword, context). Checks the address is
actually different (AUTH_EMAIL_UNCHANGED otherwise), checks the current
password, checks the address is free on both users and account, and only
then reserves an issue slot — in that order, so a mistyped password never
costs the person the 60-second cooldown. Inside one transaction: users is
locked FOR UPDATE first (see below), an email_change verification record is
created targeting the new address, the confirmation email goes to the new
address, and — when the account already has a verified address — a notice with a
cancel link goes to the old one. oldEmail is read before the transaction so it
is available for both the notice and the audit entry even though nothing on
users has changed yet.
confirmByCode(actor, otp, context) resolves the record through
VerificationTokenService.consumeByOtp("email_change", actor.id, otp) — signed
in, so the record is found by the caller's own id — then calls complete() with
403 as the banned-account status (a 401 on an authenticated route reads to
the panel as an expired session and triggers a refresh loop instead of the
suspension message).
confirmByToken(token, currentPassword, context) is public: the record is
found by the token's hash via consumeByToken, an attempt is spent with
countAttempt against a wrong password specifically (the code's own attempts
are untouched — a link route has no code to guess), and only then is the
password checked. complete() runs with 401, because there is no session for
a 403 to be mistaken for.
complete(record, context, bannedStatus) is the change itself, in one
transaction: lock users, refuse a banned or sign-in-revoked account, claim
the record (single-use — a unique-violation race after the claim is caught and
reported as AUTH_EMAIL_ALREADY_EXISTS), re-check the address is still free,
write the new address onto both users.email (with email_verified: true)
and account.account_id for the email provider, retire every other pending
verification record for the person (retireAllPendingFor), delete every session
for the user, and — when there was a prior address — send the "your sign-in
address was changed" notice to it.
Lock order is users first, always. request and complete both take
users … FOR UPDATE before touching verification, matching
PeopleAccountService.setSignIn and AuthPasswordService.resetPassword — the
reverse order deadlocks (40P01) against an administrator granting or revoking
sign-in access on the same person.
cancel(token, context) verifies an HMAC-signed cancel token (
email-change-cancel-token.util.ts, keyed off COOKIE_SECRET) rather than
looking anything up by a bare id, because the cancel link is mailed to an
address that never held a session. Three outcomes, and the service is careful
never to let one read as another: { cancelled: true } when a pending record
was deleted; { cancelled: false } when nothing matching email_change and
unconsumed exists; and a 409 AUTH_EMAIL_CHANGE_ALREADY_COMPLETED — not
cancelled: false — when the record exists but was already consumed, so
somebody whose address has just been taken over is not told the cancellation
quietly worked.
Every audit entry this service writes uses module "auth" and is explicit
(recordActivity, not the global ActivityAuditInterceptor), because the link
routes are @Public() and carry no actor for the interceptor to key on.
Addresses in metadata are always maskEmail-ed; the password, token and code
never reach the audit write. The write is best-effort, like every audit call —
a Mongo outage must not undo the email change that prompted it.
3.6 Every password write retires a pending email change
AuthPasswordService.afterPasswordWritten(userId, executor, keepSessionId?) runs
on the executor of all four password writers — self-service change
(setPassword), first password, reset (updatePasswordForUser), and mobile
change-password, which calls setPassword through MobileUserService. It does
two things on that one executor: deletes any pending email_change record
(VerificationTokenService.retirePendingEmailChanges) and ends every session
except the caller's own, when one is passed. The email-change notice sent to a
person's old address tells its owner to change the password if the change was
not theirs; this is what makes that actually stop a change somebody else
started with a stolen password, rather than leaving it live alongside a fresh
credential.
3.7 Signing in by mobile number — AuthLoginService.validateIdentifierLogin
Both login routes accept identifier as an alternative to email
(dto/login-identifier.decorator.ts holds the shared rule: exactly one of the two,
identifier trimmed to 3–254 characters). The mobile route validates it through its
DTO and the global ValidationPipe; the admin route through LocalAuthGuard (§2.2).
validateIdentifierLogin(identifier, password) sends an identifier containing @ to
validateEmailLogin, the email path, unchanged. Anything else goes to the private
validatePhonePasswordLogin:
- Normalise the input with
normalizeLoginPhone(utils/login-phone.util.ts): remove spaces, dashes, dots and parentheses; strip+977,00977or977when exactly ten digits follow; strip one0when exactly ten digits follow; the result must be exactly ten digits or it isnull. - Find the eligible people on the number with
AuthUserQueryService.findUsersByLoginPhone(nationalNumber, PHONE_LOGIN_MAX_CANDIDATES + 1), ordered byusers.idand limited to six rows. Eligibility is part of theWHEREclause:deleted_at IS NULL,banned = false,can_login = trueandemail_verified = true— the email path's own refusals (ensureUserNotBanned,ensureUserCanLogin, the verified-address check), applied as a filter rather than a throw, because a throw would answer a stranger's attempt with one relative's ban reason or confirm which account sits behind the number. An ineligible account is never a candidate and never counts towards the bound.phone_verifiedis deliberately not required: a number an operator types on a record is a sign-in key for that person without anyone proving they hold it, a risk accepted as a product decision. The limit bounds the read on an unauthenticated route while keeping "more than five" visible. The storedusers.phoneis free text — the people screens keep what the operator typed, the account screens keep E.164 — so the query comparesnormalizedLoginPhoneSql(users.phone), the same rule as a SQL expression built from the same pattern strings (LOGIN_PHONE_*_PATTERN), passed as bound parameters. Postgres has no lookahead, so "a prefix followed by exactly ten digits" is written as a capture of the ten digits replaced by itself, in both languages. No index serves the expression and none exists:usersholds one school's people, so a sequential scan per sign-in is cheap, and an expression index would be a schema change for no measurable gain.users_phone_idxon the raw column does not apply. - Read the password hashes in one query.
AuthUserQueryService.findPasswordHashesByUserIdsreads theemail-provideraccount.passwordof the first five candidates. It runs on every attempt, even with no candidates — Drizzle rendersinArrayover an empty list asfalseand the statement still reaches the database. - Run exactly
PHONE_LOGIN_MAX_CANDIDATES(5) bcrypt compares, concurrently: slot n compares against the nth candidate's hash when there is one, and against the cost-12 dummy hash otherwise (no candidate in that slot, or a candidate with no password). A dummy compare never counts as a match, whatever was typed. - Decide. More than five candidates, or a password matching two or more of them,
is 401
AUTH_PHONE_LOGIN_AMBIGUOUS; no match is 401AUTH_INVALID_CREDENTIALS; exactly one match returns that profile throughgetUserProfile.
The same work on every branch. An unknown number, one holder, a shared number and a number over the bound all pay one candidate read, one hash read and five compares before anything is decided, so the bcrypt work that dominates the reply time does not reveal whether, or by how many people, a number is used. The only branch that skips a step is an input that does not normalise — it skips the candidate read, which reveals nothing the caller did not type. A successful sign-in does more work after the decision. The compares run concurrently on the libuv thread pool rather than one after another: the same five jobs are submitted on every branch, so the wall-clock time is the same whichever is taken, and five sequential cost-12 compares would put well over a second on every phone sign-in. Every password hash this repo writes is cost 12, the same as the dummy; a hash of another cost would compare in a different time. The bound also caps how many password guesses one request can buy at five.
The profile then goes through the same handler code as an email sign-in on the same
route — AuthService.loginWithEmail on the admin route and AuthService.loginFromMobile
on the mobile route, then AuthSessionService.createSession, the active-role choice
(§3.9), device registration on the mobile route — so the session, the tokens and the
response body are identical in shape.
No failed-attempt accounting. Neither path keeps a per-account failure counter or
lockout, and the phone path must never add one: a wrong password typed against a
shared number would count against every member of the household. The AUTH_ATTEMPT
limiter on the route is the bound on guessing; it keys a mobile number on the same ten
digits normalizeLoginPhone produces, so every spelling of one number spends one
budget (§4.1).
Forgot-password, reset and email verification stay email-based: no route starts a password reset from a mobile number.
3.8 Token expiry in the login and refresh responses
AuthTokenService computes both expiries when it mints a token:
generateAccessToken sets the access expiry to now plus
JWT_ACCESS_TOKEN_TTL_SECONDS, the same value AuthModule gives the JWT signer as
expiresIn, and generateRefreshToken sets the refresh expiry to now plus
JWT_REFRESH_TOKEN_TTL_SECONDS. env.validation.ts defaults the two to 900 and
604800 seconds.
AuthSessionService carries both on SessionTokens (accessTokenExpiresAt,
refreshTokenExpiresAt, as Date). createSession writes the refresh expiry to
sessions.expires_at; rotateSessionTokens mints a fresh refresh token on every
refresh and writes its expiry over the old one, which is what makes the refresh
window slide. Neither method reads the expiry back from the row — the value returned
is the value written.
The four handlers that answer with tokens — login and refresh on
auth-session.controller.ts and on mobile/auth/auth-session.controller.ts — map
SessionTokens onto AuthTokensDto and serialise both expiries with
toISOString(), so they reach the client as ISO-8601 UTC strings. Each mapping is
checked against the DTO with satisfies AuthTokensDto, so a field added to the DTO
and missed by a handler fails type-checking.
phone-login.integration.spec.ts pins the contract over the real database: on the
admin and mobile routes both expiries are ISO-8601 UTC strings one lifetime after the
call that minted them, the refresh expiry a refresh returns is later than the one
login returned, and it equals the row's expires_at.
3.9 The role a session starts with — AuthLoginService.issueAuthResult
Permissions resolve from the session's active role alone, stored on
sessions.active_role_id. Both sign-in routes reach the private
issueAuthResult, which picks the role passed to AuthSessionService.createSession:
| Route | Service entry point | Roles held | Active role at sign-in |
|---|---|---|---|
POST /api/auth/login/email | AuthLoginService.loginWithEmail | none | none |
| exactly one | that role | ||
| several | none | ||
POST /api/mobile/auth/login/email | AuthLoginService.loginFromMobile | none | none |
| exactly one | that role | ||
several, teacher among them | teacher | ||
several, no teacher | none |
The facade passes each route through unchanged: the admin controller calls
AuthService.loginWithEmail and the mobile controller calls
AuthService.loginFromMobile. loginFromMobile calls issueAuthResult with
preferTeacherRole set; loginWithEmail leaves it at its default, false. A sole
role always wins, so the preference changes only the several-roles case and never
replaces a role the sole-role rule activates.
teacher is matched by the role's name. HeldRole carries only id, name and
isSuperadmin, and the name is a safe key: teacher is a seeded system role, role
names are unique (role.name is UNIQUE), RoleService refuses to rename a system
role (ROLE_SYSTEM_RENAME_FORBIDDEN), and StaffRolesService grants teacher to
staff with a teaching designation by the same literal name. The rule looks at nothing
else, so a superadmin who also holds teacher starts a mobile session as teacher
too.
The mobile route makes this choice because the mobile auth surface lists no roles —
GET /api/mobile/auth/me reports only the active role's name — and mounts no switch
route. A session with no active role is refused on every permission-gated route with
403 AUTH_ACTIVE_ROLE_REQUIRED (RoleGuard), so a member of staff who teaches would
otherwise be refused until they switched. Changing the session's role is
POST /api/auth/context/switch, which accepts only a role the caller holds and writes
sessions.active_role_id through AuthService.setActiveRole. A refresh
(AuthSessionService.rotateSessionTokens) never writes that column, so the role a
session acts as survives every refresh; a new sign-in creates a new session and makes
the choice again.
phone-login.integration.spec.ts ("the active role a sign-in starts with") pins the
table over the real database. For each case it checks sessions.active_role_id, the
principal JwtStrategy.validate builds for the next request, and the role that both
GET /me routes report, plus requiresRoleSelection on GET /api/auth/me. It covers
the mobile route signing in by email address and by mobile number.
4. Security and Operational Controls
- the permission list a superadmin active role is told it holds is narrower than what it may do (see below)
- DTO validation via global
ValidationPipe - endpoint throttling via
IpThrottlerGuard+@IpThrottle, keyed on the trusted client address (see §4.1) - banned-user protection via user-query checks
- token/session rotation centralized in session service
The superadmin permission list. GET /api/auth/permissions
(auth-session.controller.ts:142) answers a superadmin active role with
buildSuperadminPermissionCatalog() (:166): the full catalogue minus every code
of a module in PERSON_BOUND_SCREEN_MODULES
(packages/db/src/authorization/permission-catalog.ts:961). Those modules gate the
"My …" screens that show the caller's own student, guardian or staff record, which
a superadmin account does not have. The list only narrows what the panel shows;
RoleGuard still admits a superadmin to every route on isSuperadmin, so no API
route changes behaviour. Any other active role receives the codes that role
holds (RoleService.getPermissionsForRoleId), and a session with no active role
receives an empty list.
4.1 What the rate limiter buckets on
IpThrottlerGuard resolves the caller's address through
resolveTrustedClientIp in common/utils/trusted-client-ip.util.ts — the same
function TrustedClientIpInterceptor uses to decide what the activity log records.
One module, two consumers, because a second copy that dropped the token comparison
would turn x-internal-client-ip into an unauthenticated header and let a caller
choose their own bucket, with no error and no log line.
A forwarded address is honoured only when x-internal-client-ip-token matches
INTERNAL_CLIENT_IP_TOKEN (compared with timingSafeEqual after a length check,
which that function throws on). A numeric trust proxy is not a substitute:
measured against Express 5.2.1, with the socket peer on loopback and a
single-entry x-forwarded-for, req.ip becomes whatever the caller sent, and
Express does not validate the entry it picks. Trust is bound to a secret, not to a
hop count.
The bucket key carries the address's provenance — t: for a vouched-for
address, p: for the socket peer. Without it, a caller reaching the API directly
could spoof x-forwarded-for to a known operator's address and drain that
operator's allowance. They can still mint their own bucket; they cannot take
somebody else's.
Four key strategies:
| Strategy | Bucket | Used by |
|---|---|---|
ip | <provenance>:<address> | the default |
ip+device | <provenance>:<address>:<device fingerprint> | routes a direct client calls with its own user-agent |
ip+identifier | <provenance>:<address>:<sha256 of the caller's user id, or the submitted email/phone/identifier> | AUTH_ATTEMPT, AUTH_OTP_REQUEST, AUTH_EMAIL_CHANGE_REQUEST |
user | u:<user id> | authenticated tiers |
ip+identifier is what makes the address-keyed tiers usable once the panel
forwards a real address: it stops one person's failed sign-ins from locking out
everybody else behind the same address, while still bounding an attacker working
through a list. A signed-in caller is identified by their own account, not by a
body field. email/change and its siblings carry no email/phone/identifier
in the body, so without this every caller behind one school's shared address would
collapse into a single anon bucket and one person's retries would throttle
everybody else at that school. extractIdentifier checks req.user.id first and
only falls back to the request body when there is no session. The identifier is
hashed either way, never keyed raw, and a request carrying neither buckets under
anon.
The body field read is the first present of email, phone and identifier,
trimmed and lower-cased, then put in the canonical form sign-in matches on before
hashing. A value containing @ is an email address and is keyed as it stands. Any
other value goes through normalizeLoginPhone (modules/auth/utils/login-phone.util.ts),
so 9800100001, +977 9800100001, 09800100001 and 00977 9800100001 are one
bucket; a value that does not normalise to ten digits is keyed as typed. Keying on the
raw string would give every spelling of one number its own five attempts. The
aggregate ceiling on AUTH_ATTEMPT (sixty per address per fifteen minutes,
whatever the identifiers) sits above the per-identifier buckets.
The admin route declares @UseGuards(IpThrottlerGuard, LocalAuthGuard) in that order:
LocalAuthGuard throws on a wrong password, so with it first a failed attempt never
reached the limiter. The mobile route runs only IpThrottlerGuard and checks the
password in the handler, after it. login-throttle.integration.spec.ts pins both over
HTTP: five wrong passwords, then the right one, answers 429.
user-agent is never forwarded by any first-party proxy in this platform, and
that is load-bearing. The key is a concatenation, so accuracy in one component
constrains nothing about another: a caller-authored dimension beside a constant
address mints one bucket per value they choose, however correct the address half
is. The admin and mobile BFFs both drop the header, and the mobile BFF has a test
asserting it.
authTier: true is the only switch that fails a tier closed when Redis is
unavailable — both when exec() rejects and when it resolves with a per-command
error, where Number(null ?? 0) would otherwise sail past the limit check. There
is no account lockout and no failed-attempt counter in this module, so these
limiters are the only bound on password guessing. Every other tier fails open and
logs, authTier included. An earlier revision also failed closed for any route on
a controller whose class name began Auth; that read as a safe default and was
not one — AUTH_TOKEN_REFRESH sits on AuthSessionController and carries no
authTier, and refresh is not a guessing surface, so the heuristic 429'd every
refresh platform-wide on a Redis outage and signed every session out, a far larger
blast radius than the sign-in page being briefly unavailable. Every tier on an
auth controller that does gate guessing carries authTier: true explicitly
instead.
See Auth API Reference for exact routes and request/response shapes.
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 Module Feature Guide
Functional behavior of admin auth and customer mobile auth — login by email or mobile number, sessions, password reset, and verification.