Deepen PracticeOrdered learning track

Rate Limiting and Abuse-aware UI

Learn React Authentication, Authorization, Identity & Permission/ACL - Part 089

Rate limiting dan abuse-aware UI untuk React auth flows: login throttling, account lockout, credential stuffing, enumeration resistance, bot friction, retry semantics, support recovery, observability, dan failure modelling.

12 min read2336 words
PrevNext
Lesson 89130 lesson track72–107 Deepen Practice
#react#authentication#authorization#identity+6 more

Part 089 — Rate Limiting and Abuse-aware UI

Rate limiting di auth flow bukan sekadar “batasi request per IP”. Untuk React app, rate limiting adalah bagian dari abuse control loop:

observe abuse -> slow down attacker -> avoid account enumeration -> preserve legitimate recovery -> keep UI understandable -> feed telemetry back to backend policy

Pada sistem auth, attacker tidak selalu mencari bug teknis. Mereka sering menyalahgunakan fungsi valid:

  • login
  • password reset
  • email verification
  • MFA challenge
  • OTP resend
  • magic link
  • account creation
  • invite acceptance
  • token refresh
  • access request
  • export/download generation

Kalau UI hanya menampilkan “Something went wrong” untuk semua kasus, user legitimate akan frustrasi. Kalau UI terlalu spesifik, attacker mendapat oracle. Kalau lockout terlalu agresif, attacker bisa melakukan denial-of-service terhadap akun korban.

Mental model utama:

Auth abuse defense must be friction-aware, enumeration-resistant, recoverable, observable, and enforced server-side.

React tidak menegakkan rate limit. React menampilkan state, mengurangi retry storm, menghormati Retry-After, mencegah UX menjadi amplification channel, dan menyediakan recovery path yang aman.


1. What rate limiting is actually protecting

Rate limiting auth flow melindungi beberapa hal berbeda. Jangan gabungkan semua menjadi satu counter.

ThreatTargetTypical endpointRisk kalau salah desain
Brute forcePassword guessing/loginAccount takeover
Credential stuffingReused leaked credentials/loginMass takeover
Password sprayingOne/few passwords across many users/loginBypass account-specific lockout
OTP bombingMFA fatigue/mfa/challenge, /otp/resendUser coerced into approval
Email enumerationAccount discovery/forgot-password, /registerPrivacy leak, targeted phishing
Account creation abuseFake accounts/registerSpam, fraud, resource abuse
Token refresh abuseSession continuity/session/refreshBackend load, replay signal lost
Access request spamPermission workflow/access-requestsApprover fatigue, audit noise
Export abuseData extraction/exportsData exfiltration or cost spike

Rate limiting bukan satu fitur. Ia adalah kumpulan policy per endpoint, per actor, per resource, per tenant, per device, dan per risk context.


2. Server is the enforcement point

Frontend rate limiting seperti disable button selama 30 detik berguna untuk UX, tapi tidak punya nilai security boundary.

Attacker bisa:

  • memanggil API langsung via script
  • menghapus JavaScript delay
  • menjalankan banyak browser/session
  • memakai proxy/IP rotation
  • memakai GraphQL batching
  • replay request dari network tab
  • melewati UI dan memakai mobile/API client lain

Boundary yang benar:

React hanya melakukan:

  • disable submit saat request in-flight
  • honor Retry-After
  • show safe error message
  • avoid duplicate request
  • prevent accidental resend
  • expose support/recovery path
  • send correlation id or client request id
  • avoid leaking whether account exists

3. Enumeration-resistant UX

Auth UI sering menjadi oracle.

Bad messages:

No account found with that email.
Password is wrong for this existing account.
This account uses SSO.
This tenant does not allow password login.
This email is registered but not verified.

Pesan seperti itu membantu user, tapi juga membantu attacker membuat daftar akun valid.

Better public messages:

If the details are correct, you will receive the next step.
We could not complete sign-in. Check your details or use another sign-in method.
If an account exists, we sent recovery instructions.

Namun jangan terlalu naif. Enterprise SaaS sering punya legitimate org discovery. Misalnya user memasukkan domain perusahaan untuk diarahkan ke SSO. Di sini enumeration risk harus dinilai terhadap product requirement.

Decision matrix:

FlowPublic responseInternal log
Forgot password unknown emailSame as known emailpassword_reset_unknown_subject
Login wrong passwordGeneric failurelogin_password_mismatch
Login unknown accountGeneric failurelogin_unknown_subject
SSO requiredUsually can disclose after org discoverylogin_password_blocked_sso_required
MFA requiredSafe after primary auth successmfa_required
Account lockedUsually disclose to authenticated/recovery flow onlyaccount_temporarily_restricted

Rule:

Public message should help legitimate recovery without becoming an account-existence oracle.

4. Rate limit dimensions

A mature auth system uses multiple dimensions at once.

limit key = endpoint + tenant + normalized subject + device/session + IP/network + risk bucket + time window

Examples:

DimensionWhy it mattersFailure mode
IP/networkStops simple brute forceWeak against botnets; can hurt NAT users
Account/emailStops attack on one accountCan enable victim lockout DoS
Tenant/orgProtects enterprise boundaryOne attacker can degrade whole tenant if too strict
Device/sessionDifferentiates legitimate deviceCan be spoofed/rotated
Credential hash bucketDetects common password sprayingRequires careful privacy design
ASN/geo/risk scoreAdaptive riskCan cause false positives
Endpoint/actionPer-flow policyMissing endpoint becomes bypass
GraphQL operationPrevents batching bypassRequest-level limits alone fail

React should not compute these limits. But React needs to understand the result contract.


5. Typed abuse response contract

Avoid forcing frontend to parse strings.

Use typed errors:

{
  "type": "https://example.com/problems/auth/rate-limited",
  "title": "Too many attempts",
  "status": 429,
  "code": "AUTH_RATE_LIMITED",
  "retryAfterSeconds": 60,
  "publicMessage": "Too many attempts. Try again shortly.",
  "correlationId": "req_01J...",
  "recovery": {
    "kind": "password_reset",
    "href": "/forgot-password"
  }
}

Suggested auth abuse error codes:

export type AuthAbuseCode =
  | 'AUTH_RATE_LIMITED'
  | 'AUTH_TEMPORARILY_RESTRICTED'
  | 'AUTH_LOCKED_RECOVERY_REQUIRED'
  | 'AUTH_MFA_RATE_LIMITED'
  | 'AUTH_OTP_RESEND_RATE_LIMITED'
  | 'AUTH_SUSPICIOUS_ACTIVITY'
  | 'AUTH_REGISTRATION_RATE_LIMITED'
  | 'AUTH_ACCESS_REQUEST_RATE_LIMITED'
  | 'AUTH_EXPORT_RATE_LIMITED';

Frontend state:

type AbuseUiState =
  | { kind: 'idle' }
  | { kind: 'submitting' }
  | { kind: 'rate_limited'; retryAt: number; message: string }
  | { kind: 'restricted'; recoveryHref?: string; message: string }
  | { kind: 'failed'; message: string; correlationId?: string };

Invariant:

The server decides limit state. The client may display countdown and prevent accidental resubmit, but it must not be trusted to enforce the limit.

6. Handling 429 Too Many Requests

For auth endpoints, 429 should usually include either:

  • Retry-After header
  • typed body field like retryAfterSeconds
  • stable public message
  • correlation id

React handler:

export async function submitLogin(input: LoginInput): Promise<LoginResult> {
  const response = await fetch('/api/auth/login', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Client-Request-Id': crypto.randomUUID(),
    },
    credentials: 'include',
    body: JSON.stringify(input),
  });

  if (response.status === 429) {
    const retryAfter = parseRetryAfter(response.headers.get('Retry-After'));
    const problem = await safeProblem(response);

    return {
      kind: 'rate_limited',
      retryAt: Date.now() + retryAfter * 1000,
      message: problem.publicMessage ?? 'Too many attempts. Try again shortly.',
      correlationId: problem.correlationId,
    };
  }

  if (!response.ok) {
    const problem = await safeProblem(response);
    return {
      kind: 'failed',
      message: problem.publicMessage ?? 'We could not complete sign-in.',
      correlationId: problem.correlationId,
    };
  }

  return { kind: 'success', session: await response.json() };
}

function parseRetryAfter(value: string | null): number {
  if (!value) return 30;

  const seconds = Number(value);
  if (Number.isFinite(seconds)) return Math.max(1, Math.min(seconds, 3600));

  const date = Date.parse(value);
  if (Number.isFinite(date)) {
    return Math.max(1, Math.min(Math.ceil((date - Date.now()) / 1000), 3600));
  }

  return 30;
}

UI countdown must be cosmetic:

function RateLimitedNotice({ retryAt }: { retryAt: number }) {
  const remaining = useCountdownSeconds(retryAt);

  return (
    <div role="status">
      Too many attempts. Try again in {remaining} seconds.
    </div>
  );
}

Do not rely on countdown to enforce anything.


7. Login throttling vs account lockout

These are different controls.

Login throttling

Slows attempts.

Examples:

  • per IP: max 20 login attempts/minute
  • per account: max 5 failed attempts/15 minutes
  • per device: exponential delay after failed attempts
  • per tenant: detect distributed spray

Account lockout

Restricts account use after too many suspicious attempts.

Danger:

If an attacker can trigger lockout for a victim, lockout becomes denial-of-service.

Better model:

risk-based restriction + recovery path + no total account dead-end

Examples:

  • allow password reset even when password login restricted
  • require step-up verification instead of permanent lock
  • send notification for suspicious attempts
  • use progressive delay before lockout
  • avoid revealing lockout state to unauthenticated attackers

React UI should say:

We could not complete sign-in. Use account recovery or try again later.

Not:

This exact email is locked because someone tried 7 wrong passwords.

8. Credential stuffing UX

Credential stuffing uses known username/password pairs from other breaches. It is not simple brute force.

Signals:

  • high volume of valid-looking credential pairs
  • many usernames, few attempts each
  • distributed IPs
  • device anomalies
  • automation timing patterns
  • breached password fingerprints
  • sudden login failures across tenant

Frontend consequences:

  • do not expose precise reason
  • avoid CAPTCHA as first reflex for everyone
  • support risk-based step-up
  • allow legitimate user recovery
  • support suspicious activity notifications
  • avoid retry storms from SDK/client

Possible server response:

{
  "code": "AUTH_SUSPICIOUS_ACTIVITY",
  "status": 403,
  "publicMessage": "For your protection, complete an additional verification step.",
  "challenge": {
    "kind": "step_up",
    "href": "/verify"
  }
}

React flow:


9. MFA rate limiting and fatigue defense

MFA endpoint abuse has different failure modes from password login.

Risks:

  • OTP resend spam
  • push notification fatigue
  • user confusion
  • SMS/email cost abuse
  • recovery code brute force
  • backup factor enumeration
  • helpdesk bypass pressure

Rules:

MFA challenge is a high-value endpoint. Treat it like a transaction, not like a button click.

UI patterns:

  • disable resend while request is in-flight
  • show server-provided resend cooldown
  • do not show exact factor inventory to unauthenticated user
  • label push challenge with device/time/location if safe
  • include “This wasn’t me” recovery path
  • cap number of visible retry attempts
  • avoid auto-resend loops

Example:

function OtpResendButton({ retryAt, onResend }: Props) {
  const remaining = useCountdownSeconds(retryAt);
  const disabled = remaining > 0;

  return (
    <button type="button" disabled={disabled} onClick={onResend}>
      {disabled ? `Resend code in ${remaining}s` : 'Resend code'}
    </button>
  );
}

But server must enforce resend limit.


Password reset is often overlooked.

Threats:

  • email enumeration
  • reset email flood
  • token brute force
  • reset token replay
  • user inbox fatigue
  • phishing amplification
  • tenant/user confusion

Safe public response:

If an account exists, recovery instructions have been sent.

Backend should rate limit by:

  • normalized email
  • tenant/org
  • IP/network
  • device fingerprint/risk signal
  • global email provider/domain spikes

React must:

  • not reveal whether email exists
  • not auto-resend recovery email on page reload
  • not put reset token in analytics/logs
  • clean URL after token handoff
  • handle expired/used token generically
  • avoid prefetching sensitive reset links

11. Registration abuse-aware UI

Account creation abuse can harm product operations even when auth is technically correct.

Controls:

  • email verification
  • domain policy
  • invite-only mode
  • rate limit by IP/domain/device
  • disposable email detection
  • CAPTCHA or proof-of-work after risk threshold
  • delayed activation
  • tenant approval
  • abuse review queue

React UI states:

idle -> submitting -> verification_required -> pending_review -> active
                     -> rate_limited
                     -> domain_not_allowed
                     -> generic_failure

Be careful with domain errors. In B2B SaaS, telling user “your domain is managed by Acme Corp SSO” may be acceptable after org discovery, but not on arbitrary unauthenticated probes.


12. Access request abuse

Access request workflow can become spam against approvers.

Abuse patterns:

  • repeatedly requesting high privilege
  • requesting access to many resources
  • requesting access after denial
  • social pressure through comments
  • mass-notifying approvers
  • abusing “urgent” flag

React should support:

  • one active request per subject-action-resource
  • cooldown after denial
  • clear status of pending request
  • access scope preview
  • required justification
  • approver visibility rules
  • no repeated notification spam

Server must enforce:

  • request deduplication
  • approver authorization
  • separation of duties
  • request TTL
  • audit trail
  • policy validation before grant

13. Export and report generation throttling

Exports are auth-adjacent because they turn authorized access into bulk data movement.

Risks:

  • data exfiltration
  • expensive queries
  • tenant-level resource exhaustion
  • repeated report generation
  • stale permission after queued job
  • signed URL leakage

React UX:

  • show export quota/cooldown if server exposes it
  • show queued/pending state
  • allow cancellation when possible
  • warn when export includes sensitive fields
  • revalidate permission at download time
  • avoid auto-downloading after session changes

Server-side policy:

authorize_create_export(subject, filters, fields, tenant)
authorize_process_export(job_owner, job_scope)
authorize_download_export(subject, job, current_permissions)

Never assume permission at job creation is enough for download unless product policy explicitly says snapshot access is allowed.


14. Avoiding client-induced retry storms

Auth clients often create their own abuse.

Bad pattern:

401 -> refresh -> failed -> retry -> 401 -> refresh -> retry -> repeat in every tab

For auth abuse-aware client:

  • one refresh in-flight per tab/process
  • cross-tab refresh coordination if using browser tokens
  • cap retries per request
  • do not retry 403
  • do not retry 429 until retry window
  • cancel polling on logout
  • stop background jobs after session invalidation
  • respect server Retry-After
  • centralize auth error handling

Example policy:

export function shouldRetryAuthRequest(error: ApiError, attempt: number): boolean {
  if (attempt >= 1) return false;
  if (error.status === 401 && error.code === 'SESSION_EXPIRED') return true;
  if (error.status === 429) return false;
  if (error.status === 403) return false;
  return false;
}

For TanStack Query:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry(failureCount, error) {
        const e = normalizeApiError(error);
        if (e.status === 401 || e.status === 403 || e.status === 429) return false;
        return failureCount < 2;
      },
    },
    mutations: {
      retry(failureCount, error) {
        const e = normalizeApiError(error);
        if (e.status === 401 || e.status === 403 || e.status === 429) return false;
        return failureCount < 1;
      },
    },
  },
});

15. CAPTCHA and friction design

CAPTCHA is not a default answer. It is friction.

Problems:

  • accessibility impact
  • false positives
  • user frustration
  • bypass by human-solver farms
  • third-party script risk
  • privacy implications
  • degraded conversion

Use CAPTCHA or challenge only when risk warrants it.

Better staged model:

normal -> soft throttling -> step-up/challenge -> restricted -> recovery/support

React UI should make friction explicit:

We need to verify this request because of unusual activity.

Not:

You look suspicious.

16. Abuse-aware UX copy library

Reusable public messages:

ConditionSafe copy
Wrong credential/unknown account“We could not complete sign-in. Check your details or use another sign-in method.”
Forgot password“If an account exists, recovery instructions have been sent.”
Rate limited“Too many attempts. Try again shortly.”
Temporary restriction“For your protection, this action is temporarily unavailable.”
MFA resend cooldown“You can request another code shortly.”
Suspicious activity“Complete an additional verification step to continue.”
Support escalation“Contact support and include this reference code.”
Access request cooldown“A request is already pending or was recently reviewed.”

Internal logs can be precise. Public UI should be safe.


17. Observability for auth abuse

Metrics:

login_attempts_total{tenant, outcome, method}
login_rate_limited_total{tenant, dimension}
password_reset_requested_total{tenant, outcome}
mfa_challenge_created_total{factor, outcome}
mfa_resend_rate_limited_total{tenant}
credential_stuffing_signal_total{tenant, severity}
account_lockout_total{tenant, reason}
account_recovery_started_total{tenant}
access_request_rate_limited_total{tenant, resource_type}
export_rate_limited_total{tenant}

Logs should include:

  • correlation id
  • user/subject if known
  • normalized account identifier hash, not raw email in every log
  • tenant id
  • endpoint/action
  • limit dimension triggered
  • retry-after
  • IP/risk metadata, subject to privacy policy
  • user agent/device id if allowed
  • result code

Avoid logging:

  • raw password
  • OTP
  • reset token
  • magic link token
  • refresh token
  • full Authorization header
  • sensitive claims

18. Abuse policy as state machine

A login attempt can be modeled as a state machine, not as an if/else block.

Benefit:

  • easier telemetry
  • easier testing
  • safer public messages
  • clearer recovery
  • less accidental bypass

19. React implementation skeleton

A minimal abuse-aware login hook:

export function useLoginForm() {
  const [state, setState] = useState<AbuseUiState>({ kind: 'idle' });

  async function submit(input: LoginInput) {
    if (state.kind === 'submitting') return;
    if (state.kind === 'rate_limited' && Date.now() < state.retryAt) return;

    setState({ kind: 'submitting' });

    const result = await submitLogin(input);

    switch (result.kind) {
      case 'success':
        authEvents.emit({ type: 'LOGIN_SUCCEEDED', session: result.session });
        return;
      case 'rate_limited':
        setState({
          kind: 'rate_limited',
          retryAt: result.retryAt,
          message: result.message,
        });
        return;
      case 'failed':
        setState({
          kind: 'failed',
          message: result.message,
          correlationId: result.correlationId,
        });
        return;
    }
  }

  return { state, submit };
}

Key points:

  • prevents accidental double-submit
  • respects server retry window
  • does not reveal precise account state
  • centralizes auth event
  • avoids automatic infinite retry

20. Testing matrix

ScenarioExpected
Repeated wrong loginUI shows generic failure until server returns typed rate limit
Unknown account forgot passwordSame public message as known account
Known account forgot passwordSame public message as unknown account
429 with Retry-After secondsUI disables resubmit until countdown ends
429 with HTTP dateUI parses date safely
Missing Retry-AfterUI uses conservative fallback
Browser reload during cooldownServer still enforces limit
User edits local JS stateServer still enforces limit
MFA resend spamUI respects server cooldown
Multiple tabs login retryNo client retry storm
GraphQL batched login attemptsServer counts each operation or blocks batching
Account lockout attempt against victimRecovery path remains available
Access request spamDuplicate/pending state is shown
Export spamServer returns typed quota response

21. Common anti-patterns

Anti-pattern 1: frontend-only rate limit

if (attempts > 5) disableButton();

This is UX only. It can be bypassed instantly.

Anti-pattern 2: precise login errors

User exists but password is wrong.

This leaks account existence.

Anti-pattern 3: account lockout as first line of defense

This creates DoS risk.

Anti-pattern 4: retry all failed auth requests

This causes retry storms and hides abuse signals.

Anti-pattern 5: CAPTCHA for every user

This punishes legitimate users and often does not stop real automation.

Anti-pattern 6: rate limit only /login

Attackers move to password reset, OTP resend, registration, magic link, GraphQL batching, or token refresh.


22. Engineering checklist

Use this checklist during review:

[ ] Every auth-sensitive endpoint has server-side abuse policy.
[ ] Login errors are enumeration-resistant.
[ ] Forgot-password response is account-existence neutral.
[ ] 429 responses include safe typed problem details.
[ ] UI respects Retry-After but server remains authority.
[ ] Account lockout cannot be trivially abused for DoS.
[ ] Recovery path exists for legitimate users.
[ ] MFA resend/challenge has independent limits.
[ ] Password reset/magic link has independent limits.
[ ] Registration has abuse controls.
[ ] Export/access request flows have throttling where needed.
[ ] Client avoids infinite auth retry loops.
[ ] GraphQL batching cannot bypass limits.
[ ] Logs do not include secrets or raw tokens.
[ ] Metrics distinguish rate limit, lockout, restriction, and suspicious activity.
[ ] Support UI has correlation id but no sensitive internals.

23. Final mental model

Rate limiting in auth is not a spinner, not a disabled button, and not a single Redis counter.

It is a control system:

Server observes behavior.
Server enforces friction.
Server preserves recovery.
Server avoids enumeration.
React displays safe state.
React avoids accidental amplification.
React gives legitimate users a path forward.

A top-tier React engineer does not ask only:

How do I disable the login button after too many attempts?

They ask:

Which actor is being slowed, what abuse is being detected, how do we avoid becoming an oracle, how do legitimate users recover, and how do we know it works in production?

References

  • OWASP Authentication Cheat Sheet — login throttling, lockout caveats, authentication controls.
  • OWASP API Security Top 10 2023 API2 Broken Authentication — examples of rate limiting bypass through GraphQL batching.
  • OWASP Automated Threats to Web Applications — credential stuffing, account creation, and automated abuse taxonomy.
  • OWASP Denial of Service Cheat Sheet — lockout as potential DoS vector.
  • MDN HTTP Retry-After and 429 Too Many Requests semantics.
Lesson Recap

You just completed lesson 89 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.