ADR-0007: Rate limiting & abuse protection live at the edge, not in the app

  • Status: Accepted — promoted to core 2026-09-10 (from expenses/infra/)
  • Date: 2026-05-31
  • Scope: core (any backend deployed behind an edge: a CDN with a web application
    firewall)

Promoted per 0009-one-knowledge-monorepo when dracomania adopted it
(0007-accounts-sessions-and-access-by-actor, 2026-09-10 amendment).

Context

Public endpoints are abuse surfaces: login, registration, token refresh, password reset, a
second-factor check, anything that answers whether an address has an account. The reflex
is to add in-app rate limiting (a Bucket4j bucket, a servlet filter or interceptor).

Our backends deploy behind CloudFront + AWS WAF, which already shapes requests at the
edge (Expenses, for instance, tuned the WAF SizeRestrictions_BODY rule for large CSV
uploads). In-app limiting would duplicate that, add state (per-instance counters that
disagree across instances unless they share a store), and still not protect against
volumetric/DDoS traffic the way the edge does.

Decision

IP and endpoint throttling is an edge concern (CloudFront / AWS WAF), not an application
concern.
Do not add Bucket4j, a rate-limit filter, or any in-app throttling keyed on
the caller’s address or the route. Tune the WAF / CloudFront rules instead.

What a project must configure

The edge only protects what it has a rule for, and those rules live in the AWS console, not
in any repository. So:

  • Before a public deployment, a WAF rate-based rule covers the project’s public
    authentication endpoints
    : at least login, and every other unauthenticated endpoint that
    accepts a guessable secret (a reset code, a second-factor code) or reveals whether an
    account exists.
  • The project lists, in its own docs, the edge rules it relies on, and checks that they
    exist rather than assuming it. An ADR that leaves brute-force control to the edge is
    relying on a rule somebody must have created.

The exception: a limit the edge cannot express

A limit keyed on something the edge cannot see (an account, a code, a plan) belongs in the
application, scoped to that one case. General IP and endpoint throttling stays at the edge.
Both projects now have such limits:

  • per code: dracomania allows at most 5 wrong attempts on a password reset code
    (0007-accounts-sessions-and-access-by-actor, 2026-09-10 amendment);
  • per account: both projects refuse to issue a new reset code within a cooldown of the
    last one (Expenses does the same for verification codes, answering 429);
  • per plan: Expenses meters AI usage per user against the plan’s budget
    (0008-monetize-intelligence-not-data-in).

Consequences

  • The app stays stateless and simple; protection is centralized where it’s cheap and
    volumetric-attack-resistant.
  • Accepted: the protection lives outside version control. A rule removed in the console
    leaves no diff, and nothing in the app notices. Listing the relied-on rules in the
    project’s docs is the mitigation.
  • An in-app limit carries a product reason and is written where its key lives (the code,
    the account, the plan). It is never a general throttle under a product name.
  • Checked in both backends: no in-app IP or endpoint throttling (Expenses on 2026-05-31 and
    again on 2026-09-10; dracomania on 2026-09-10). Their only in-app limits are the ones
    listed above.