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.

Sliding-window limit on an availability endpoint A timeline of requests from one client against a limit of five requests per ten seconds, showing requests allowed until the window fills, a 429 response, and requests allowed again as old ones age out. Allowed 1 2 3 4 5 6 7 Rejected 429 429 429 429 Window first 10 s window requests age out 0 s 5 s 10 s 15 s 20 s
A sliding window counts requests in the last ten seconds at every moment, so a burst is stopped quickly and service resumes smoothly as old requests age out.

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.

Client back-off after a 429 The browser's debounced availability check receives a 429 with Retry-After, stops calling for that period, shows a neutral hint, and the final submission is checked authoritatively on the server. Username field Availability API Submit handler GET ?u=ada_l 429, Retry-After: 20 blockedUntil = now + 20 s; neutral hint user submits POST /signup (authoritative unique check) 201 or 422 "taken"
Rate limiting degrades the live hint, never the form: the user can still submit, and the server decides.

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.

Live check versus uniform response for email sign-up Two columns comparing a live email availability check with a uniform sign-up response that emails the address owner instead. Live "already registered" check ✓ immediate, clear feedback ✗ reveals which emails have accounts ✗ needs strict rate limits • common on consumer products Uniform response + email ✓ reveals nothing on the page ✓ owner gets a helpful email either way ✗ feedback arrives by email, not inline • preferred for sensitive services
A uniform response removes the enumeration channel; a live check is friendlier but must be rate limited and accepted as a disclosure.

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.

← Back to Validation Security and Abuse Prevention