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
../../backend/src/main/java/com/motorph/payroll/security/ratelimit/LoginRateLimitFilter.java../../backend/src/main/java/com/motorph/payroll/security/service/LoginAttemptService.java— the DB-backed layer behind it../../e2e/helpers/serverGrid.ts— 429-aware login helper../security/authentication.md— §1b- 0004-argon2id-transparent-rehash.md, 0007-per-client-isolated-stacks.md