Skoolsewa - School Management System Docs
Developer Resourcesauth

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-verification controllers

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 of email and identifier (400 VALIDATION_FAILED), hands an email body to the passport email strategy (LocalStrategy) unchanged, and validates an identifier body itself (trimmed length 3–254, a non-empty password) before calling AuthService.validateIdentifierLogin. Either way the profile lands on request.user. It deliberately does not apply the whole EmailLoginDto to an email body — doing so would turn requests the route has always answered into 400s.
  • JwtStrategy + JwtAuthGuard for protected routes

2.3 Core services

  • AuthService — thin facade; delegates to the collaborators below
  • AuthPasswordService — password reset/set flows
  • AuthInvitationService — redeems an account invitation: verifies the address and sets the first password in one transaction
  • AuthEmailChangeService — 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 from PeopleAccountService.sendPasswordResetLink. Expires after OTP_EXPIRY_MINUTES, 15 minutes by default.
  • account_invite — minted by PeopleAccountService when an administrator grants somebody sign-in access. It passes expiresInMs: 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:

  1. SELECT … FOR UPDATE on the users row.
  2. The four refusals, re-asserted against the locked row: banned, canLogin false, and the account's email no longer equal to the invitation's target.
  3. The atomic claim (consumeAccountEntry), whose boolean is checked.
  4. updatePasswordForUser, on the same executor.
  5. users.email_verified, written only when the record's channel is email and 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:

  1. Normalise the input with normalizeLoginPhone (utils/login-phone.util.ts): remove spaces, dashes, dots and parentheses; strip +977, 00977 or 977 when exactly ten digits follow; strip one 0 when exactly ten digits follow; the result must be exactly ten digits or it is null.
  2. Find the eligible people on the number with AuthUserQueryService.findUsersByLoginPhone(nationalNumber, PHONE_LOGIN_MAX_CANDIDATES + 1), ordered by users.id and limited to six rows. Eligibility is part of the WHERE clause: deleted_at IS NULL, banned = false, can_login = true and email_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_verified is 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 stored users.phone is free text — the people screens keep what the operator typed, the account screens keep E.164 — so the query compares normalizedLoginPhoneSql(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: users holds 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_idx on the raw column does not apply.
  3. Read the password hashes in one query. AuthUserQueryService.findPasswordHashesByUserIds reads the email-provider account.password of the first five candidates. It runs on every attempt, even with no candidates — Drizzle renders inArray over an empty list as false and the statement still reaches the database.
  4. 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.
  5. Decide. More than five candidates, or a password matching two or more of them, is 401 AUTH_PHONE_LOGIN_AMBIGUOUS; no match is 401 AUTH_INVALID_CREDENTIALS; exactly one match returns that profile through getUserProfile.

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:

RouteService entry pointRoles heldActive role at sign-in
POST /api/auth/login/emailAuthLoginService.loginWithEmailnonenone
exactly onethat role
severalnone
POST /api/mobile/auth/login/emailAuthLoginService.loginFromMobilenonenone
exactly onethat role
several, teacher among themteacher
several, no teachernone

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:

StrategyBucketUsed 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
useru:<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.