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.
Related
- 0013-sessions-and-refresh-tokens —
/auth/refreshreviewed against this ADR and left
at the edge - 0014-two-factor-single-use-challenge (Expenses) — leaves brute-force on the
second-factor check to the edge - 0007-accounts-sessions-and-access-by-actor (dracomania) — adopts this, and keeps the
reset-code attempt limit in the app - 0008-monetize-intelligence-not-data-in (Expenses) — where the per-plan AI quotas live,
and why - 0001-three-step-git-flow — deploy posture favors edge/infra simplicity