A password-reset page looks like a small form, but it triggers work with a real cost: finding an account, creating a token and sending email. Without limits, automated requests can consume resources, repeatedly email account owners, or use different responses to discover which addresses are registered.
When updating ParitLAB, we had two goals: reduce automated requests and preserve a simple recovery path for a real customer. We chose several background controls instead of forcing every visitor to solve an image challenge or click a CAPTCHA.
Layer 1: a honeypot for form-filling programs
The form contains a website field that is visually hidden and removed from the keyboard tab order. A person never sees or completes it, while a basic bot that fills every input often does. If the server receives a value in this field, it rejects the request before looking up an account or sending email.
This layer creates no friction and requires no external service. Its limitation is equally clear: a bot that understands the page structure can learn to skip the field. A honeypot is an inexpensive first filter, not a complete defence.
Layer 2: minimum form-completion time
When the server renders the recovery form, it stores a timestamp in the visitor's session. A submission arriving in less than three seconds is treated as unlikely to be human and is stopped. This catches scripts that request the form and immediately issue a POST, without collecting more personal data.
Completion time cannot be the only signal. Password managers and browsers can fill an email address quickly, and an attacker can add a delay. We therefore use it alongside persistent limits rather than treating speed as proof.
Layer 3: rate limits that survive process restarts
Requests that pass the early checks are counted in the database. The application stores hashes of the IP address and normalized email instead of their original text. The current policy allows fewer than ten attempts per IP in one hour and fewer than three attempts for the same account in one hour. Events older than 24 hours are removed during normal processing.
A database counter matters on a PHP site because several workers may serve traffic and processes can restart at any time. A lock and transaction keep concurrent requests from reading the same old counter and slipping through together. The implementation supports both MySQL in production and SQLite in local development so the rule can be exercised before release.
Do not reveal whether an account exists
The page returns the same general result whether the address exists, the request is limited, or the honeypot catches it. This reduces account enumeration, where an attacker submits a list of addresses and compares responses. Email is sent only after every control passes and a matching account is found.
What we verified
- A form submitted too quickly is rejected.
- A populated honeypot is rejected.
- The first three test attempts are accepted and the fourth is limited.
- A separate account remains usable under its own allowed conditions.
- Thai and English pages return HTTP 200 and retain CSRF protection.
- Customer, licence, order and support-record counts stay unchanged when the new table is installed.
Limits and follow-up
IP limits can affect several legitimate people behind the same network, while a distributed attacker can rotate addresses. The thresholds therefore need enough room for normal recovery. The useful signals to monitor are blocked-request rate, outbound reset-email volume and real support complaints. If the attack changes, a risk-based challenge, proof of work, or CAPTCHA can be added for suspicious traffic only. Starting with observable layers keeps the ordinary recovery path short while leaving room for a stronger response.