fix(security): add rate limiting to login and password-reset endpoints #111
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
The
/api/users/me(used for login verification) and/api/auth/forgot-passwordendpoints have no rate limiting. An attacker can:Fix
Add Resilience4j to the backend and configure a
RateLimiteron the affected endpoints:Dependency (pom.xml):
application.yml:
Controller:
Alternatively, a simpler in-memory approach using a
ConcurrentHashMap<String, AtomicInteger>keyed by IP is acceptable for a single-instance deployment.Acceptance Criteria
👨💻 Felix Brandt — Senior Fullstack Developer
Questions & Observations
@RateLimiterand a manualConcurrentHashMap. I'd commit to Resilience4j — it's externally configurable, handles edge cases (burst tolerance, timeout), and the@RateLimiterannotation keeps the controller clean. TheConcurrentHashMapalternative has no TTL cleanup and will grow unboundedly until the process restarts.fallbackMethodfor@RateLimitermust match the exact signature of the annotated method plus aRequestNotPermittedparameter. A mismatched signature silently falls through to the default exception handler — write the test to catch this./api/users/mereally the login endpoint? The issue description says it's "used for login verification" — but login is typically aPOST /api/auth/loginor Spring Security's/login. Worth verifying which endpoint the brute-force attack surface actually is before configuring the rate limiter.Suggestions
@WebMvcTestthat fires 11POST /forgot-passwordrequests in a loop and asserts the 11th returns 429. Make it fail first (without rate limiting), then implement.shouldReturn429WhenPasswordResetRateLimitExceeded.limitRefreshPeriod: 15mmakes integration tests slow if you wait for real time. Use a test profile with a shortlimitRefreshPeriod: 1sso tests don't needThread.sleep().🔒 Nora "NullX" Steiner — Application Security Engineer
Questions & Observations
X-Forwarded-For. Spring Boot must be configured to trust proxy headers, otherwiserequest.getRemoteAddr()returns the Caddy container IP — and the rate limiter blocks Caddy, not the attacker:Suggestions
Retry-Afterheader to the 429 response so clients (and users) know when to try again:🧪 Sara Holt — QA Engineer & Test Strategist
Test Strategy
Rate limiting is notoriously tricky to test because the real timer makes tests slow. The key is a configurable test profile.
Integration test —
@SpringBootTestwith test profile:Edge Cases to Cover
/forgot-passwordand the login endpoint independently (separate counters)Observations
Thread.sleep()is acceptable here because it's testing time-based behaviour — the test profile sets a 1s refresh to keep it fast. This is not a flaky test; it's a controlled time dependency.🏗️ Markus Keller — Application Architect
Questions & Observations
request.getRemoteAddr()which returns Caddy's container IP in production. This requiresserver.forward-headers-strategy: frameworkinapplication.ymland trusting theX-Forwarded-Forheader from Caddy only (not from arbitrary clients). This is not a minor detail — without it, the rate limiter is non-functional in production.# NOTE: in-memory rate limiter — not suitable for horizontal scaling) in the YAML config block.authinstance name in the issue's YAML applies to both login and forgot-password. Consider separate named instances (auth-loginandauth-forgot-password) with different limits — 10/15min for login is different from 3/15min for password reset.Suggestions
ResponseEntity<ErrorResponse>with the project's standard error body, not a raw string.🎨 Leonie Voss — UI/UX Designer & Accessibility Strategist
Questions & Observations
Suggestions
error_rate_limit_loginwith a message that includes the wait time: "Zu viele Anmeldeversuche. Bitte warte 15 Minuten."Retry-Afterheader is available in the response — even just "try again after 15 minutes" is enough.🚀 Tobias Wendt — DevOps & Platform Engineer
Questions & Observations
server.forward-headers-strategy: frameworkis a deployment requirement. Without it,HttpServletRequest.getRemoteAddr()returns the Caddy container's IP in production — every request looks like it comes from the same host, and the rate limiter will block all traffic after the first N requests. This config must land inapplication.yml(base) orapplication-prod.ymlbefore the rate limiter goes live. It's not a code change but it's as critical as the feature itself.resilience4j_ratelimiter_available_permissions,resilience4j_ratelimiter_waiting_threads) are available without any extra instrumentation. Good operational visibility.application-test.ymloverride for a shortlimitRefreshPeriod(1s) during tests is a clean pattern — document it so the next developer understands why the test profile has different limits.Suggestions
server.forward-headers-strategy: frameworktoapplication.ymlas part of this PR — it's a dependency of the feature, not a separate concern.application-prod.yml, override the rate limit thresholds to match the agreed production values. The dev defaults inapplication.ymlcan be more generous to avoid friction during local development.Audit confirmation (2026-05-07)
Pre-prod audit confirms zero rate-limiting infrastructure in
backend/src/main/java/:Suggested AC additions tied to audit findings
/api/auth/login,/api/auth/forgot-password,/api/auth/register.AuthenticationFailureBadCredentialsEvent→AuditService.logAfterCommit()with masked email + source IP. This closes audit finding F-14 (failed-login audit trail) in the same PR.Retry-Afterheader +RFC 9457-style ProblemDetail body.audit_logwithkind = LOGIN_FAILED.Suggested impl
bucket4j-spring-boot-starter(Maven) orresilience4j-ratelimiter. The latter integrates with the same Resilience4j stack we'd use for OCR resilience (audit F-09), so prefer it if you want a single resilience library across the codebase.Tracked in audit doc as F-05 (Critical) and F-14 (High). See
docs/audits/2026-05-07-pre-prod-architectural-review.md.