Short one-time codes can no longer be guessed without limit

FixSecurityService
Shipped
September 17, 2026 at 4:48 AM UTC
Author
Kamo
Commit
357d3a0

Every short code the platform mails or texts was checked with no count: - POST /api/recover/email/verify-code took the 8-hex-character reset code ALONE and matched it against every account's outstanding reset, for 30 minutes. A match on any account became a recovery session for that account. - POST /api/recover/sms/verify: a 6-digit texted code for a known number, 10 minutes, no count. - POST /api/recover/questions/verify and /seed/verify: security answers (a colour, a city) and seed phrases, no count. - POST /api/security/verify-email {userId, code}: a 6-digit code, 24 hours, no session. The first success mints an auto-login key into the account. - POST /api/account/phone/verify: a 6-digit code proving a new recovery phone number, no count. - POST **************** had a five-attempt limit that never took effect. It wrote the count and then threw, the default @Transactional rule rolled the write back, and the controller's own transaction was marked rollback-only. CodeAttemptLimiter (Redis, fixed windows, counted BEFORE the comparison so parallel guesses cannot race past it): - 5 attempts an hour per subject: the account, the address typed, the phone number, or the code. The attempt that uses the fifth spends the code (reset rows marked used, texted codes consumed, registration codes invalidated, phone-change code consumed). A success clears the subject. - 30 attempts an hour per public client address, across all of these endpoints. Private hops are skipped, since one proxy address would stand for everyone. - 50 code-only reset attempts per 10 minutes, platform-wide. Refusals are 429 with Retry-After and retryAfterSeconds in the body. The limiter fails open on a Redis outage, like LoginThrottleService and VerificationResendRateLimiter. All comparisons are now constant-time. The reset code is bound to an account when the request names one: verify-code accepts `email` (the address typed on the reset page, resolved the way /email/initiate resolves it) and checks only that account's resets. An unknown address is counted exactly like a known one. The code-only shape still works for a login page that has not deployed yet (kamo-login sends the address in the same change set), under the platform-wide budget. Methods that write what a wrong code spent now declare noRollbackFor. MFA recovery codes were already behind LoginThrottleGate and are 60 bits. The Vonage phone check enforces its own attempt limit. Tests: - CodeAttemptLimiterTest: windows, the fifth marked last, address and platform budgets, hashed keys, fail-open. - RecoveryCodeAttemptsTest: account binding, spend on the fifth, refusal after, unknown address counted, code-only still works and runs out, SMS, security answers. - **************** - CodeAttemptWritesCommitTest: each method through a real TransactionInterceptor commits its refusal. - RecoveryAttemptsWireTest: address and IP reach the service; 429 mapping on all three controllers. Twelve mutants (each limit, spend, binding, budget and noRollbackFor removed) each fail a test.

All changes

Like what you see shipping?

All of it arrives in your workspace on its own. Start on the free plan and read this page again in a month.

Start Free ForeverView Pricing