Authentication brute-force and enumeration protection β
SOC 2 evidence for ENG-1401 (CC6.6, CC6.1). Firebase Authentication supplies the control; this page records that it is on, what a locked-out user sees, and how support unlocks them.
The backend still has no Nest throttler. That gap is out of scope here β Firebase covers the authentication surface only.
What Firebase actually provides β
| Control | Kind | Where |
|---|---|---|
| Email enumeration protection | Project toggle (emailPrivacyConfig.enableImprovedEmailPrivacy) | Identity Platform docs |
| Per-account failed-password lockout | Always-on abuse detection (TOO_MANY_ATTEMPTS_TRY_LATER / auth/too-many-requests) | Firebase Auth, not a console slider |
Per-IP quotas (SMS, verification, account creation, createAuthURI) | Always-on Identity Toolkit limits | Quotas |
Email enumeration protection is not in Terraform (google_identity_platform_config has no email_privacy_config field). Confirm it in the Firebase console or with the probe script below. Projects created on or after 15 Sep 2023 have it on by default; leave it on.
Console path for a screenshot (attach to the Linear ticket):
Firebase Console β Authentication β Settings β User actions β Email enumeration protection (recommended)
Observed configuration and lockout β tell-ia-dev β
Recorded 2026-08-27T13:19:53Z by pnpm --filter agri-backend probe:auth-protections -- --throttle.
| Check | Result |
|---|---|
emailPrivacyConfig.enableImprovedEmailPrivacy | true |
| Email/password provider | enabled |
| Phone provider | enabled |
| Known email + wrong password | HTTP 400 INVALID_LOGIN_CREDENTIALS |
| Unknown email + dummy password | HTTP 400 INVALID_LOGIN_CREDENTIALS |
| Password-reset for known vs unknown email | both HTTP 200 OK |
| Failed password attempts until lockout | 5 consecutive signInWithPassword calls on a disposable account, then HTTP 400 TOO_MANY_ATTEMPTS_TRY_LATER |
The probe creates a throwaway Firebase user, never SMS, and deletes the user afterwards. Re-run it against staging and production with that environment's FIREBASE_PROJECT / FIREBASE_CLIENT_EMAIL / FIREBASE_PRIVATE_KEY and attach the JSON plus a console screenshot to ENG-1401.
set -a; source apps/agri-backend/.env; set +a # or the staging/prod env
pnpm --filter agri-backend probe:auth-protections -- --throttle--enable-eep PATCHes the toggle on if a project still has it off.
Residual timing delta β
Status code and error message match for known vs unknown identifiers. Wall clock on two samples was ~200ms slower for the registered identifier (~580ms vs ~360ms). That is Identity Toolkit behaviour we cannot configure. Our UIs show the same copy either way.
OTP path β phone numbers β
Identity is phone-number based on mobile. An enumeration oracle here would be a different response, status, or client-visible error for a registered vs unregistered number before the caller proves they have the phone.
Current path (mobile useSendOtpMutation):
- Client calls Firebase
signInWithPhoneNumberdirectly. There is no backend pre-check. POST /auth/check-phone(200{ exists: true }vs 404) does not exist. It used to, and would have been the oracle. It was removed.- After a correct OTP,
GET /auth/mereturnsUSER_NOT_REGISTEREDwhen the Firebase user has no Mongo account. That is after SMS possession, not a pre-auth scan of our user directory.
Web email/password sign-in does not send OTP. Phone OTP is mobile-only.
What a locked-out user sees β
Firebase lockout is temporary (minutes; Google does not publish the cooldown). It is not the same as an admin-disabled account (disabled: true).
| Surface | Credential mismatch | Too many attempts | Admin-disabled |
|---|---|---|---|
Web (SignIn) | signIn.failedSignIn | signIn.tooManyAttempts | signIn.accountDisabled |
| Mobile email | auth.errors.invalidCredential (same copy for unknown email and wrong password) | auth.errors.tooManyRequests | auth.errors.userDisabled |
| Mobile OTP | Firebase invalid-verification-code / quota errors | auth.errors.tooManyRequests or auth.errors.quotaExceeded | n/a at send time |
Web used to map lockout to βcheck your credentialsβ. It now names the lockout.
Support recovery β
There is no Firebase console button that clears a TOO_MANY_ATTEMPTS window. Google lifts it after the cooldown.
Temporary throttle (this control)
Tell the user to wait, then retry. A password-reset email still works (Identity ToolkitsendOobCodeis a different quota). Trying from another network can help if the bucket is per-IP.User forgot the password (not a throttle)
In-app βForgot passwordβ, or agri-admin Send password-reset link. Direct set only if they cannot receive email β see Admin user operations.Account disabled (
disabled: truein Firebase)
Super-admin Enable account on agri-admin/data/users/:id(Manage auth). That is a different flag from throttle. Sessions already issued can last up to ~1 hour; see the token caveat on that page.Still stuck after cooldown
Confirm the user is not disabled, reset the password if needed, check Firebase Auth β Usage for project-level SMS / sign-in quota. Do not disable email enumeration protection to βmake errors clearerβ.
Break-glass password script: pnpm --filter agri-backend set:firebase-password. It bypasses the admin audit trail β log the incident in the channel.
Published per-IP quotas (always on) β
From Identity Platform quotas:
- Verification requests: 150 / IP / hour
- Verification SMS: 50 / IP / minute, 500 / IP / hour
- New account creation: 100 / IP / hour
createAuthURI(sign-in method lookup): 120 / IP / hour
Tightening identitytoolkit.googleapis.com further is optional in Cloud Console quotas. Do not raise these without a traffic review.
Out of scope β
- Nest
@nestjs/throttleron our own API (deliberately a separate ticket). - reCAPTCHA Enterprise / password policy (unset on
tell-ia-devat probe time). - Signup still returns
EMAIL_EXISTSeven with enumeration protection on; Google documents that leftover. Mitigations they recommend (App Check, reCAPTCHA on sign-up) are follow-ups.