Rate Limiting Async Validation Endpoints
An endpoint that answers “is this username taken?” as the user types is a gift to anyone who wants to list your users, test leaked email addresses, or guess valid coupon codes — unless it is rate limited. This recipe adds a sliding-window limiter keyed by IP and by session, returns 429 Too Many Requests with Retry-After, makes the browser back off gracefully without blocking the form, and keeps the final submission’s authoritative check separate. The client side stays on the site’s standard pattern: availability verdicts land in setCustomValidity() and submission goes through reportValidity() from the Constraint Validation API, with a rate-limited check treated as “unknown”, never as “taken”.
When to Rate-Limit Validation Endpoints
Every endpoint that exists only to answer a validation question should be limited, because by design it reveals information. The common ones are:
- Username, handle and slug availability — reveals which accounts exist.
- Email “already registered” checks — reveals who uses your service, and verifies leaked address lists.
- Coupon, voucher and gift-card validation — invites brute-force guessing of valid codes.
- Address and postcode lookups — often metered third-party APIs you pay for per call.
The client-side debounce in guides such as username validation rules and reserved names reduces traffic from honest users, but it is not a limit: a script calls the endpoint directly. The server must count.
Minimal Working Rate Limiter
// A sliding-window limiter over a shared store (Redis sorted set shown; any atomic store works).
import type { Redis } from "ioredis";
export interface LimitResult { ok: boolean; remaining: number; retryAfter: number }
export async function slidingWindow(redis: Redis, key: string, limit: number, windowMs: number, now = Date.now()): Promise<LimitResult> {
const windowStart = now - windowMs;
const member = `${now}:${Math.random().toString(36).slice(2)}`;
const [, , [, count], [, oldest]] = (await redis
.multi()
.zremrangebyscore(key, 0, windowStart) // drop requests outside the window
.zadd(key, now, member) // record this request
.zcard(key) // count requests in the window
.zrange(key, 0, 0, "WITHSCORES") // oldest request, for Retry-After
.pexpire(key, windowMs)
.exec()) as [unknown, unknown, [null, number], [null, string[]]];
if (count <= limit) return { ok: true, remaining: limit - count, retryAfter: 0 };
await redis.zrem(key, member); // rejected requests don't consume capacity
const oldestTs = Number(oldest?.[1] ?? now);
return { ok: false, remaining: 0, retryAfter: Math.max(1, Math.ceil((oldestTs + windowMs - now) / 1000)) };
}
// The endpoint: two keys — per IP (generous) and per session (tighter).
export async function GET(request: Request): Promise<Response> {
const ip = clientIp(request); // from the proxy's trusted header, not user input
const session = sessionId(request) ?? `anon:${ip}`;
const [byIp, bySession] = await Promise.all([
slidingWindow(redis, `avail:ip:${ip}`, 120, 60_000),
slidingWindow(redis, `avail:s:${session}`, 30, 60_000),
]);
const verdict = !byIp.ok ? byIp : bySession;
if (!verdict.ok) {
return new Response(null, { status: 429, headers: { "retry-after": String(verdict.retryAfter), "cache-control": "no-store" } });
}
const name = new URL(request.url).searchParams.get("u")?.trim().toLowerCase() ?? "";
const available = name.length >= 3 && !(await usernames.exists(name));
return Response.json({ available }, { headers: { "ratelimit-remaining": String(verdict.remaining), "cache-control": "no-store" } });
}
// Client: back off on 429, never report "taken" for an unknown answer.
let blockedUntil = 0;
export async function checkAvailability(value: string, signal: AbortSignal): Promise<boolean | null> {
if (Date.now() < blockedUntil) return null; // unknown: don't call
const res = await fetch(`/api/usernames/available?u=${encodeURIComponent(value)}`, { signal });
if (res.status === 429) {
blockedUntil = Date.now() + (Number(res.headers.get("retry-after")) || 30) * 1000;
return null;
}
if (!res.ok) return null;
return (await res.json()).available as boolean;
}
// In the field handler:
const available = await checkAvailability(field.value, controller.signal);
field.setCustomValidity(available === false ? "That username is taken." : "");
status.textContent = available === null ? "We'll check availability when you submit." : "";
A null answer means “we don’t know”, and the form treats it exactly that way: no error, a neutral hint, and the authoritative check at submission. Turning a rate-limit response into “taken” would be wrong, and turning it into an error message would punish a human for a limit designed to stop scripts.
Rate Limit Option Reference
| Option | Type | Default | Purpose |
|---|---|---|---|
| Algorithm | sliding window | — | Smooth limits without fixed-window bursts at boundaries |
| Per-IP limit | requests / window | 120 / min | Stops volume from one source; generous for shared IPs |
| Per-session limit | requests / window | 30 / min | Tighter limit per user; unaffected by shared IPs |
Retry-After |
seconds | computed | Tells clients exactly when to retry |
| Rejected requests | not counted | on | A blocked client’s retries don’t extend its own block |
cache-control: no-store |
header | on | Availability answers must never be cached and replayed |
| Client back-off | blockedUntil |
honours Retry-After |
Avoids hammering during a block |
Take the client IP from your proxy’s trusted header (CF-Connecting-IP, the last hop of X-Forwarded-For set by your own load balancer), never from a header the client can set, or the limit is bypassed by sending a different fake IP with every request.
Verification Steps
import { describe, it, expect } from "vitest";
import RedisMock from "ioredis-mock";
import { slidingWindow } from "./limit";
describe("slidingWindow", () => {
it("allows up to the limit, then rejects with Retry-After", async () => {
const redis = new RedisMock();
const t = 1_000_000;
for (let i = 0; i < 5; i++) expect((await slidingWindow(redis as any, "k", 5, 10_000, t + i)).ok).toBe(true);
const blocked = await slidingWindow(redis as any, "k", 5, 10_000, t + 5);
expect(blocked.ok).toBe(false);
expect(blocked.retryAfter).toBe(10);
expect((await slidingWindow(redis as any, "k", 5, 10_000, t + 10_001)).ok).toBe(true);
});
});
Edge Cases and Failure Modes
Limiter store outages. If Redis is down, failing closed blocks every sign-up; failing open removes protection. For availability hints, fail open (the hint is advisory and the submission is checked anyway); for security-critical endpoints such as sign-in attempts, fail closed with a clear message.
Distributed attacks. A botnet spreads requests across thousands of IPs, each under the per-IP limit. Per-session and per-target limits help — for example, limiting how many different usernames one session may probe — and a global circuit breaker on the endpoint caps the worst case.
Limits that catch real users. Offices and mobile carriers share IPs. Keep per-IP limits generous, rely on per-session limits for precision, and watch 429 rates by network in your metrics.
Enumeration through the final submission. Rate-limiting the availability endpoint does nothing if the sign-up endpoint answers “email already registered” without limits. Apply the same limiter to the submission path for the fields that reveal information.
Rate Limiting at the Edge
If your application sits behind a CDN or edge platform, the cheapest place to enforce a limit is before the request reaches your origin at all. Most edge platforms offer rate-limiting rules keyed on path and client IP, which can absorb a scripted flood without a single database query. Keep the application-level limiter anyway: edge rules usually cannot see sessions or accounts, so they are the coarse outer layer while the application enforces the precise per-session budget described above.
Reducing What the Endpoint Reveals
Rate limits bound how fast information leaks; product decisions determine how much leaks per request. For usernames on a public platform, availability is inherently public — profiles exist at /u/name — so a limited availability endpoint reveals nothing new. For email addresses, consider not offering a live “already registered” check at all: accept the sign-up, and email the address owner either a welcome message or a “you already have an account” message. The response on the page is identical either way, which removes the enumeration channel entirely while keeping the experience clear for real users. For coupon codes, make them long and random enough that guessing is infeasible, and limit per account as well as per IP. The trade-offs between specific and uniform messages are discussed in the validation security and abuse prevention topic.
Frequently Asked Questions
Why do validation endpoints need rate limiting?
Endpoints that answer questions such as "is this username taken" or "is this email registered" reveal information. Without limits they can be queried in bulk to enumerate users or test leaked address lists.
What should the client do when it receives a 429 from an availability check?
Treat the answer as unknown: do not mark the field invalid, show a neutral hint, stop calling until Retry-After has passed, and let the server's check at submission decide.
Should I rate-limit by IP address or by user?
Both. Per-IP limits stop volume from one source and should be generous because many users share IPs; per-session or per-account limits are tighter and precise. Take the IP from a trusted proxy header only.
What happens if the rate limiter's storage is unavailable?
For advisory checks like availability hints, fail open, because the submission is checked anyway. For security-critical endpoints like sign-in, fail closed with a clear message.
Related Guides
- Validation Security and Abuse Prevention — rate limiting among the other controls.
- Throttling vs Debouncing Server Validation — reducing honest traffic on the client.
- Implementing Async Email Availability Checks — the client side of the endpoint.
- Handling 422 Unprocessable Content Responses — handling 429 alongside other statuses.