Skip to main content

ADR-0006: In-memory Bucket4j rate limiting on auth endpoints

Status: Accepted (retroactive) Date: 2026-07-24

Context

Login endpoints needed throttling for two concrete reasons: credential stuffing, and the fact that every login verification costs ~19 MiB of Argon2id work (ADR-0004), making unthrottled login a cheap denial-of-service lever.

The standard "serious" answer is a distributed limiter (Redis-backed Bucket4j, or a gateway feature). But this system deploys as one backend process per client (ADR-0007) — there is no second instance for state to be shared with, and adding Redis to every client stack just for a login counter would be infrastructure for a problem the deployment model doesn't have.

Decision

LoginRateLimitFilter implements per-IP token buckets with Bucket4j, held in a plain ConcurrentHashMap<String, Entry> in process memory:

  • Applies to the login/register endpoints only, keyed by client IP.
  • Bucket: 10 requests/minute with gradual refill (~1 token per 6 s); exceeding it returns HTTP 429 with a JSON body, before the request ever reaches Argon2id or the database.
  • The filter's own javadoc records the escape hatch: if the deployment model ever changes to multiple instances, swap the map for one of Bucket4j's distributed backends (JCache/Redis) — the bucket math stays the same.
  • This is one layer of three: exponential account lockout (LoginAttemptService) and ALTCHA (ADR-0005) sit behind it.

Consequences

Positive

  • Zero extra infrastructure: no Redis, no gateway config, nothing new in the per-client compose template.
  • The expensive Argon2id hash is protected by the cheapest possible check — a map lookup — at the very front of the filter chain.
  • Deterministic and testable: the e2e helper (e2e/helpers/serverGrid.ts) documents and retries around the exact refill behavior.

Negative

  • State resets on every restart/redeploy — an attacker who can predict deploy windows gets a fresh bucket, and a restart forgives an in-progress brute force (account lockout, which is DB-backed, still holds).
  • Not shared across instances. If the single-backend assumption ever breaks (horizontal scaling, blue-green), each instance enforces its own limit and the effective ceiling multiplies silently. The code comment is the only guard against forgetting this.
  • Per-IP keying punishes NAT'd offices: a whole client office behind one egress IP shares one 10/min bucket, so simultaneous morning logins can 429 legitimate users — the e2e suite already hits this with parallel workers from one IP and needs jittered retries.

References