Skip to content

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 ​

ControlKindWhere
Email enumeration protectionProject toggle (emailPrivacyConfig.enableImprovedEmailPrivacy)Identity Platform docs
Per-account failed-password lockoutAlways-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 limitsQuotas

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.

CheckResult
emailPrivacyConfig.enableImprovedEmailPrivacytrue
Email/password providerenabled
Phone providerenabled
Known email + wrong passwordHTTP 400 INVALID_LOGIN_CREDENTIALS
Unknown email + dummy passwordHTTP 400 INVALID_LOGIN_CREDENTIALS
Password-reset for known vs unknown emailboth HTTP 200 OK
Failed password attempts until lockout5 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.

bash
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):

  1. Client calls Firebase signInWithPhoneNumber directly. There is no backend pre-check.
  2. POST /auth/check-phone (200 { exists: true } vs 404) does not exist. It used to, and would have been the oracle. It was removed.
  3. After a correct OTP, GET /auth/me returns USER_NOT_REGISTERED when 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).

SurfaceCredential mismatchToo many attemptsAdmin-disabled
Web (SignIn)signIn.failedSignInsignIn.tooManyAttemptssignIn.accountDisabled
Mobile emailauth.errors.invalidCredential (same copy for unknown email and wrong password)auth.errors.tooManyRequestsauth.errors.userDisabled
Mobile OTPFirebase invalid-verification-code / quota errorsauth.errors.tooManyRequests or auth.errors.quotaExceededn/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.

  1. Temporary throttle (this control)
    Tell the user to wait, then retry. A password-reset email still works (Identity Toolkit sendOobCode is a different quota). Trying from another network can help if the bucket is per-IP.

  2. 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.

  3. Account disabled (disabled: true in 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.

  4. 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/throttler on our own API (deliberately a separate ticket).
  • reCAPTCHA Enterprise / password policy (unset on tell-ia-dev at probe time).
  • Signup still returns EMAIL_EXISTS even with enumeration protection on; Google documents that leftover. Mitigations they recommend (App Check, reCAPTCHA on sign-up) are follow-ups.