Rate-Limited Login
Client-side exponential backoff for auth forms to mitigate brute-force attacks.
What Problem This Solves
Prevent rapid-fire login attempts on the client side by implementing exponential backoff after consecutive failures.
Important: Client-side rate limiting is defense-in-depth only. Always implement server-side rate limiting.
Prerequisites
react-hook-formzodsonner(or your toast library)
Files
auth-rate-limiter.ts
ts
type RateLimiterConfig = {
/** Maximum consecutive failures before lockout */
maxFailures: number;
/** Initial backoff delay in milliseconds */
initialDelayMs: number;
/** Backoff multiplier (exponential) */
backoffMultiplier: number;
/** Maximum backoff delay in milliseconds */
maxDelayMS: number;
};
const DEFAULT_CONFIG: RateLimiterConfig = {
maxFailures: 5,
initialDelayMs: 1000,
backoffMultiplier: 2,
maxDelayMS: 30_000,
};
export class AuthRateLimiter {
private failureCount = 0;
private isLocked = false;
private readonly config: RateLimiterConfig;
constructor(config: Partial<RateLimiterConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
isRateLimited(): boolean {
return this.isLocked || this.failureCount >= this.config.maxFailures;
}
recordSuccess(): void {
this.failureCount = 0;
this.isLocked = false;
}
recordFailure(): number {
this.failureCount += 1;
if (this.failureCount >= this.config.maxFailures) {
this.isLocked = true;
}
const delay = Math.min(
this.config.initialDelayMs * this.config.backoffMultiplier ** (this.failureCount - 1),
this.config.maxDelayMS,
);
return delay;
}
getFailureCount(): number {
return this.failureCount;
}
reset(): void {
this.failureCount = 0;
this.isLocked = false;
}
getRemainingAttempts(): number {
return Math.max(0, this.config.maxFailures - this.failureCount);
}
}login-form.tsx
tsx
import { zodResolver } from "@hookform/resolvers/zod";
import { useRef } from "react";
import { Controller, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AuthRateLimiter } from "./auth-rate-limiter";
const loginSchema = z.object({
email: z.string().email("Please enter a valid email address."),
password: z.string().min(1, "Password is required."),
});
type LoginFormValues = z.infer<typeof loginSchema>;
type LoginFormProps = {
onSubmit: (values: LoginFormValues) => Promise<void> | void;
};
export function LoginForm({ onSubmit }: LoginFormProps) {
const rateLimiterRef = useRef(new AuthRateLimiter());
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: { email: "", password: "" },
});
const handleSubmit = async (values: LoginFormValues) => {
const limiter = rateLimiterRef.current;
if (limiter.isRateLimited()) {
toast.error("Too many failed attempts. Please try again later.");
return;
}
try {
await onSubmit(values);
limiter.recordSuccess();
toast.success("Login successful");
} catch (error) {
limiter.recordFailure();
toast.error(error instanceof Error ? error.message : "Login failed");
}
};
return (
<form onSubmit={form.handleSubmit(handleSubmit)}>
<Controller
name="email"
control={form.control}
render={({ field, fieldState }) => (
<div>
<label>Email</label>
<input {...field} type="email" />
{fieldState.error && <span>{fieldState.error.message}</span>}
</div>
)}
/>
<Controller
name="password"
control={form.control}
render={({ field, fieldState }) => (
<div>
<label>Password</label>
<input {...field} type="password" />
{fieldState.error && <span>{fieldState.error.message}</span>}
</div>
)}
/>
<button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Logging in..." : "Login"}
</button>
</form>
);
}Usage
tsx
import { LoginForm } from "./login-form";
import { useAuthAction } from "./auth-store";
export function LoginPage() {
const { setSession } = useAuthAction();
const handleLogin = async (values: LoginFormValues) => {
const session = await authAdapter.login(values);
setSession(session);
};
return <LoginForm onSubmit={handleLogin} />;
}How It Works
| Failure # | Delay Before Next Attempt | Total Wait Time |
|---|---|---|
| 1 | 1s | 1s |
| 2 | 2s | 3s |
| 3 | 4s | 7s |
| 4 | 8s | 15s |
| 5 | 16s | 31s |
| 6+ | Locked | - |
After 5 failures, the account is locked until recordSuccess() is called (on a successful login).