A rate limiter placed before schema validation counts rejected payloads as if they were submissions. On a form that posts every attempt, a visitor who mistypes five times in a minute, against a limit of five a minute, has their sixth and correct submission refused — and if the refusal is designed to look like success, they are told it worked.
The short version
- Validation failures are not submissions. Counting them makes a user's own mistakes exhaust their allowance.
- Honeypot trips should count, so an address that trips the honeypot is throttled sooner on everything else it sends.
- So the order is: honeypot, then validate, then limit. Not limit, then everything else.
- If a throttled request is answered like a success — a reasonable design, to stop callers mapping the limit — then this ordering bug is silent by construction. That is what makes it worth checking rather than assuming.
How the bug is born
You add a limiter to a public POST route. The obvious place is the top of the handler, before the work: refuse early, spend nothing. That instinct is right for expensive work and wrong here, because the first thing after it is validation, and validation rejects things that were never submissions.
We wrote exactly this. The route took a unit, then validated, then processed. An adversarial review caught it before it reached anyone.
The detail that made it real rather than theoretical: the form is rendered with noValidate, so the browser does not block a malformed attempt — every submit reaches the server and every rejection is a server-side 400. Five of those in a minute is not a stretch. An empty name, a bad email address, then a message under the minimum length three times. The sixth attempt, the one that was finally correct, would have hit the limit.
What the visitor would have seen
This is the part that turns a design mistake into a lost lead. Throttled requests were answered with the same status and body as success, deliberately, so that a caller could not map the limit by watching responses.
Applied to a valid submission that was refused, the same design says: 200, "Message received", no row in the database, no email to anyone. The visitor believes they have made contact. Nobody has any record that they tried.
It also defeats the obscurity it was built for
There was a second consequence, and it undoes the reason the responses were made identical in the first place.
Throttled plus invalid returned 200. Unthrottled plus invalid returned 400 VALIDATION. So anyone could map the limit exactly: post a payload with one field missing, repeatedly, and watch for the flip from 400 to 200. The concealment held for well-formed payloads and evaporated for malformed ones, which are free to generate.
Fixing the ordering closed both problems at once. Once validation runs first, every payload that reaches the limiter is well-formed, so throttled and successful responses really are identical in status, body and headers for everything that gets that far. Not in timing: a throttled request skips the store write and the email call, so it answers faster.
The order, and the reason for each position
parse → honeypot (counts, answers like success) → validate (400, costs nothing) → limit → process
Honeypot first, and it costs a unit. A hidden field filled in means a bot. Charging it means an address that trips the honeypot also spends the allowance its later, clean-looking submissions would use, so a bot that mixes payloads is throttled sooner. It does not stop the honeypot requests themselves; those are answered like success either way. The cost of this choice, and it is a real one: a bot can spend a shared address's allowance, so a person behind the same NAT pays for it. We took that trade knowingly and wrote a test that states it, rather than discovering it later.
Validation second, and it costs nothing. A payload that fails the schema was never a submission. Charging for it means a user's own errors deny their next correct attempt.
The limit third. Now it only counts real submissions.
Two smaller things in the same area
A blank part is a typo, not a zero. Number("") is 0 in JavaScript (MDN, Number), so a configuration value of "5," — a trailing comma — would have parsed as "five a minute, none a day" and closed the route, with every response still looking like success. Review caught it before it shipped. Treat a blank part as invalid and fall back to the default instead.
A deliberate closure should say so. Concealing a throttle is worth something, because a caller could otherwise map the limit. Concealing a closure protects nothing — everyone is refused — and it leaves the operator who closed the route unable to tell it apart from an open one with their own smoke test. Our closed state answers 503, not a success shape.
What to check in yours
Three questions, and the first is the one that finds this bug:
- Does a request that fails validation consume a unit? Post five invalid payloads, then one valid one, and see whether the valid one lands.
- Does a throttled response differ from a successful one in status, body or headers — and does that difference change with the payload's validity?
- Where does the address come from? Ours is the first
x-forwarded-forelement, which is trustworthy on Vercel because Vercel overwrites that header (Vercel, request headers), and is client-controlled almost everywhere else (MDN, X-Forwarded-For). That assumption belongs in your documentation, not in your head.


Maestro development preview · synthetic session data